Compare commits

..

13 Commits

Author SHA1 Message Date
Ed Zynda 7a8a5b185f fix: auto-initialize extension context in kit.New()
Extensions were being loaded automatically by SetupAgent but the context
was never initialized unless the SDK user explicitly called
SetExtensionContext. This left extensions with a zero-value Context where
all function fields are nil.

Now kit.New() automatically calls SetExtensionContext with minimal defaults
(CWD, Model, Interactive=false) when extensions are loaded. SDK users can
still call SetExtensionContext to override with richer implementations
(TUI callbacks, prompts, etc.).

Combined with the normalizeContext() safety net in the runner, extensions
are now guaranteed to work in SDK mode without explicit context wiring.
2026-03-27 15:01:40 +03:00
Ed Zynda 1e2e33f039 refactor(ui): remove drainScrollback() calls and function
Remove the last remaining call to drainScrollback() at the end of
Update() and delete the no-op stub function that was maintained during
migration.

The drainScrollback() mechanism was part of the old inline-mode
rendering approach that used tea.Println to flush content to the
terminal's scrollback buffer. With the alt-screen refactor:

- scrollbackBuf field was removed in TAS-6
- appendScrollback() calls were removed in TAS-7
- drainScrollback() function is now completely removed

All history content is now rendered in-app via renderHistoryRegion()
in View().
2026-03-27 14:58:59 +03:00
Ed Zynda 52719baf1f refactor(ui): replace appendScrollback with appendHistoryEntry
Remove all appendScrollback() calls and the no-op stub function.
All content now flows exclusively through appendHistoryEntry() to
the historyEntries timeline, which is rendered in View() via
renderHistoryRegion().

Updated helpers:
- printUserMessage, printAssistantMessage, printToolResult
- printErrorResponse, printSystemMessage, printExtensionBlock
- printCompactResult, flushStreamContent
- flushStreamAndPendingUserMessages, restoreSessionHistory
- Raw extension output handler, shell command result handler

Part of alt-screen scrollback refactor (TAS-7).
2026-03-27 14:55:14 +03:00
Ed Zynda f0074e8c81 refactor(ui): remove drainScrollback contract for alt-screen mode
- Remove scrollbackBuf field from AppModel (replaced by historyEntries)
- Make drainScrollback() a no-op stub (callers removed in TAS-8)
- Make appendScrollback() a no-op stub (replaced in TAS-5)
- Update tests to not expect tea.Println commands

The history timeline is now rendered directly in View() via
renderHistoryRegion() instead of being flushed via tea.Println.

Part of alt-screen-scrollback-refactor (TAS-6).
2026-03-27 14:51:15 +03:00
Ed Zynda aa2fc80575 feat(ui): set AltScreen=true on all View() return paths
Update AppModel.View() to set AltScreen=true on tea.View for all
interactive view paths:
- Tree selector overlay
- Model selector overlay
- Session selector overlay
- Overlay dialog
- Main layout

This ensures the TUI uses alt-screen mode consistently as required
by the unified BubbleTea architecture spec (R1).
2026-03-27 14:47:36 +03:00
Ed Zynda c64898f9cf feat(ui): add Shift+Up/Down for line-by-line history scrolling
Add line-by-line scroll key handlers for the history viewport:

- Shift+Up: scroll history up by one line (disables follow-mode)
- Shift+Down: scroll history down by one line (re-enables follow-mode at bottom)

Both handlers are only active in stateInput or stateWorking states,
complementing the existing page-level controls (PgUp/PgDown, Ctrl+Home/End).
2026-03-27 14:45:25 +03:00
Ed Zynda ceeacc7455 feat(ui): handle window resize with anchor preservation for history viewport
Add history scroll offset adjustment to the WindowSizeMsg handler:

- Follow mode: No change needed - renderHistoryRegion() pins to bottom
- Non-follow mode: Clamp offset to new valid range to preserve top-visible line

Implementation uses uiVis(), calculateHistoryStreamHeight(), and
historyMaxOffset() to compute the valid offset range after resize.

Anchor semantics:
- Viewport shrinks: top-visible line preserved as anchor
- Viewport grows: same top line stays visible with more content below
- Content shorter than viewport: offset clamped to show all content
2026-03-27 14:43:05 +03:00
Ed Zynda 89ea9f6c63 feat(ui): add page up/down and Ctrl+Home/End for history scrolling
Add keyboard handlers for navigating the history viewport:
- PgUp/PageUp: scroll up by one page (minus 2 lines for context)
- PgDown/PageDown: scroll down by one page
- Ctrl+Home: jump to top of history
- Ctrl+End: jump to bottom and re-enable follow-mode

Handlers are active in stateInput and stateWorking only, not in modal
selectors. Follow-mode is disabled when scrolling up and re-enabled
when reaching the bottom.
2026-03-27 14:41:33 +03:00
Ed Zynda ae33c959c9 feat(ui): implement follow-mode semantics for history viewport
Add helper methods to manage follow-mode state during history scrolling:

- historyMaxOffset(): calculate max valid scroll offset
- scrollHistoryUp(): scroll up N lines, disables follow-mode
- scrollHistoryDown(): scroll down N lines, re-enables follow at bottom
- scrollHistoryToTop(): jump to top, disables follow-mode
- scrollHistoryToBottom(): jump to bottom, re-enables follow-mode
- isHistoryAtBottom(): check if viewport is at bottom

Follow-mode semantics:
- When historyFollow=true: viewport stays pinned to bottom
- Scrolling up: historyFollow becomes false
- Scrolling to bottom: historyFollow becomes true again

These methods will be called by key handlers in TAS-13/TAS-15.
2026-03-27 14:39:12 +03:00
Ed Zynda 71fa1d20f2 fix(ui): correct UIVisibility type in calculateHistoryStreamHeight
Fix build error from previous iteration where uiVisibility was used
instead of UIVisibility in the calculateHistoryStreamHeight() function
signature.

This completes TAS-11 (Phase 3: Implement scrollable history rendering
region) which includes:
- renderHistoryRegion() with viewport windowing and follow-mode
- rebuildHistoryCache() for efficient dirty-flag-based cache rebuild
- historyTotalLines() helper for scroll calculations
- calculateHistoryStreamHeight() for proper height allocation
- View() integration with history region rendering
2026-03-27 14:37:26 +03:00
Ed Zynda 7c98ab921b fix: normalize nil Context function fields to no-ops in SetContext
Extensions running via the SDK (without a fully-wired SetExtensionContext
call) would panic with 'reflect.Value.Call: call of nil function' when
calling any ctx method like ctx.PrintBlock().

normalizeContext() now replaces every nil function field in Context with
a safe no-op stub before storing it in the runner, so extension handlers
can never crash on a missing callback regardless of how Kit is embedded.
2026-03-27 13:59:59 +03:00
Ed Zynda 96d8513c9f feat(ui): add appendHistoryEntry and dual-write to history timeline
Add new appendHistoryEntry helper method that appends entries to the
historyEntries timeline for alt-screen mode. Update all print helpers
to dual-write to both scrollbackBuf (legacy tea.Println path) and
historyEntries (new alt-screen rendering path).

Updated methods:
- printUserMessage, printAssistantMessage, printToolResult
- printErrorResponse, printSystemMessage, printExtensionBlock
- printCompactResult, flushStreamContent
- flushStreamAndPendingUserMessages, renderSessionHistory
- handleShellCommandResult, raw ExtensionPrintEvent handling

Mark appendScrollback as deprecated with migration note. Both paths
are maintained for backward compatibility during the migration.

Part of alt-screen scrollback refactor (TAS-10).
2026-03-27 13:56:40 +03:00
Ed Zynda 84ee92f78f feat(ui): add history entry data model and state fields for alt-screen scrollback
Add foundation types and fields for migrating from tea.Println pipeline
to in-app scrollback timeline (Phase 1 of alt-screen refactor):

- Add historyEntry struct with Kind, Content, Timestamp fields
- Add historyEntries, historyOffset, historyFollow fields to AppModel
- Add historyRenderCache and historyDirty for performance optimization
- Mark scrollbackBuf as deprecated (to be removed in Phase 2)

Refs: TAS-2, TAS-4
2026-03-27 12:55:40 +03:00
226 changed files with 8695 additions and 40934 deletions
-79
View File
@@ -1,79 +0,0 @@
name: Bug Report
description: Report a bug or issue with Kit
title: "fix: "
labels: ["bug"]
body:
- type: textarea
id: description
attributes:
label: Bug Description
description: What happened? What did you expect to happen?
placeholder: |
The BorderColor field in ToolRenderConfig is documented but never applied
during tool rendering. I expected the tool block to render with my custom
color, but it uses the default styling instead.
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to Reproduce
description: Provide clear steps to reproduce the issue
placeholder: |
1. Create an extension with `api.RegisterToolRenderer(ext.ToolRenderConfig{...})`
2. Set `BorderColor: "#89b4fa"` in the config
3. Run a tool that uses this renderer
4. Observe the border color is not applied
render: markdown
validations:
required: true
- type: textarea
id: code
attributes:
label: Relevant Code / Configuration
description: Paste any code, configuration, or error messages
placeholder: |
```go
api.RegisterToolRenderer(ext.ToolRenderConfig{
ToolName: "bash",
DisplayName: "Shell",
BorderColor: "#a6e3a1", // This is ignored!
Background: "#1e1e2e", // This is ignored!
})
```
render: go
- type: input
id: component
attributes:
label: Affected Component
description: Which part of Kit is affected?
placeholder: e.g., extensions, ui, tool rendering, session management
- type: input
id: version
attributes:
label: Kit Version
description: What version of Kit are you running?
placeholder: e.g., v0.1.0, commit hash, or "main"
- type: textarea
id: context
attributes:
label: Additional Context
description: Any other context, proposed fixes, or related issues
placeholder: |
The issue appears to be in `internal/ui/messages.go:RenderToolMessage()`
which ignores the BorderColor and Background fields from ToolRendererData.
- type: checkboxes
id: terms
attributes:
label: Checklist
options:
- label: I've searched existing issues and this hasn't been reported yet
required: true
- label: I've tested with the latest version of Kit
required: false
-11
View File
@@ -1,11 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Kit Documentation
url: https://github.com/mark3labs/kit/tree/main/www/pages
about: Check the documentation before filing an issue
- name: Extension Examples
url: https://github.com/mark3labs/kit/tree/main/examples/extensions
about: See working extension examples for reference
- name: Discussions
url: https://github.com/mark3labs/kit/discussions
about: For questions, ideas, or general discussion
-40
View File
@@ -1,40 +0,0 @@
name: Documentation Issue
description: Report missing, incorrect, or unclear documentation
title: "docs: "
labels: ["documentation"]
body:
- type: textarea
id: description
attributes:
label: Documentation Issue
description: What's wrong or missing in the documentation?
placeholder: |
The ToolRenderConfig documentation mentions BorderColor and Background fields,
but the code doesn't actually use them. The docs should either be updated
to reflect reality, or the bug should be fixed.
validations:
required: true
- type: input
id: location
attributes:
label: Documentation Location
description: Where is the affected documentation?
placeholder: e.g., README.md, examples/extensions/tool-renderer-demo.go, pkg/kit docs
- type: textarea
id: suggestion
attributes:
label: Suggested Improvement
description: How should the documentation be improved?
placeholder: |
Add a note that BorderColor and Background are not yet implemented,
or fix the bug and document the correct behavior.
- type: checkboxes
id: terms
attributes:
label: Checklist
options:
- label: I've checked that this documentation issue still exists in the latest version
required: true
@@ -1,64 +0,0 @@
name: Feature Request
description: Suggest a new feature or enhancement for Kit
title: "feat: "
labels: ["enhancement"]
body:
- type: textarea
id: description
attributes:
label: Feature Description
description: What would you like to see added or changed?
placeholder: |
I'd like to be able to customize the border color of tool result blocks
dynamically based on the tool type or result status.
validations:
required: true
- type: textarea
id: motivation
attributes:
label: Motivation / Use Case
description: Why is this feature needed? What problem does it solve?
placeholder: |
When running multiple tools in sequence, it's hard to visually distinguish
between file reads (blue), shell commands (green), and errors (red)
without custom border colors.
validations:
required: true
- type: textarea
id: proposed
attributes:
label: Proposed Implementation
description: How do you think this should work? (optional)
placeholder: |
Extend `ToolRenderConfig` to accept a function that receives the tool
result and returns a color based on the content:
```go
BorderColorFunc: func(result string, isError bool) string {
if isError {
return "#f38ba8"
}
return "#89b4fa"
}
```
render: go
- type: checkboxes
id: alternatives
attributes:
label: Alternatives Considered
options:
- label: I've considered workarounds or alternative approaches
required: false
- type: checkboxes
id: terms
attributes:
label: Checklist
options:
- label: I've searched existing issues and this hasn't been requested yet
required: true
- label: This feature aligns with Kit's design philosophy (TUI-first, extension-based)
required: false
-1
View File
@@ -3,7 +3,6 @@
.env
.kit/*
!.kit/extensions/
!.kit/prompts/
aidocs/
*.log
/kit
+34 -74
View File
@@ -28,15 +28,11 @@ type lintResult struct {
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")
ctx.Print("go-edit-lint extension loaded - will run gopls and golangci-lint on Go file edits")
})
// 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
@@ -47,72 +43,30 @@ func Init(api ext.API) {
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,
)
report := runGoDiagnostics(ctx.CWD, absPath)
// Check if there are issues and add explicit prompt for the LLM to react
goplsIssues, lintIssues := countIssues(report)
hasIssues := goplsIssues > 0 || lintIssues > 0
var enhanced string
if hasIssues {
enhanced = e.Content + "\n\n" + report + "\n\n⚠️ DIAGNOSTICS FOUND: Please review the issues above and fix them before proceeding."
} else {
enhanced = e.Content + "\n\n" + report
}
// Show TUI message block for diagnostics visibility (only if there are issues)
if hasIssues {
// Show TUI block so the user sees it too.
var msgLines []string
msgLines = append(msgLines, fmt.Sprintf("Files: %s", strings.Join(fileNames, ", ")))
msgLines = append(msgLines, fmt.Sprintf("File: %s", filepath.Base(absPath)))
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))
}
msgLines = append(msgLines, "", "⚠️ Please fix these issues before proceeding.")
borderColor := "#f9e2af" // yellow
if goplsIssues > 0 && lintIssues > 0 {
@@ -124,16 +78,9 @@ func Init(api ext.API) {
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",
})
}
return &ext.ToolResultResult{Content: &enhanced}
})
}
@@ -159,6 +106,18 @@ func resolveGoFilePath(inputJSON, cwd string) (string, bool) {
return absPath, true
}
func runGoDiagnostics(cwd, absPath string) string {
gopls := runGopls(cwd, absPath)
lint := runGolangCILint(cwd, "./...")
return fmt.Sprintf(
"<go_diagnostics file=%q>\n[gopls]\n%s\n\n[golangci-lint]\n%s\n</go_diagnostics>",
filepath.Base(absPath),
formatToolResult(gopls, "No diagnostics."),
formatToolResult(lint, "No lint issues."),
)
}
func runGopls(cwd, absPath string) lintResult {
ctx, cancel := context.WithTimeout(context.Background(), diagnosticsTimeout)
defer cancel()
@@ -219,9 +178,7 @@ func formatToolResult(res lintResult, emptyFallback string) string {
out := strings.TrimSpace(res.Output)
if out == "" {
if res.Err == nil {
if emptyFallback != "" {
lines = append(lines, emptyFallback)
}
lines = append(lines, emptyFallback)
}
} else {
lines = append(lines, out)
@@ -240,15 +197,17 @@ func truncate(s string, max int) string {
}
func countIssues(report string) (goplsCount, lintCount int) {
// Extract gopls section
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]
// Count non-empty lines excluding the header and "No diagnostics." message
for _, line := range strings.Split(goplsSection, "\n") {
line = strings.TrimSpace(line)
if line != "" && line != "[gopls]" && line != "No diagnostics." && !strings.HasPrefix(line, "#") {
if line != "" && line != "[gopls]" && line != "No diagnostics." {
goplsCount++
}
}
@@ -256,6 +215,7 @@ func countIssues(report string) (goplsCount, lintCount int) {
if lintStart != -1 && endTag != -1 {
lintSection := report[lintStart:endTag]
// Count non-empty lines excluding the header and "No lint issues." message
for _, line := range strings.Split(lintSection, "\n") {
line = strings.TrimSpace(line)
if line != "" && line != "[golangci-lint]" && line != "No lint issues." {
-37
View File
@@ -1,37 +0,0 @@
---
description: Run ACP smoke test against opencode/kimi-k2.5 to verify JSON-RPC stdio works
---
Run the ACP smoke test to verify the Kit ACP server works correctly over JSON-RPC stdio with streaming responses.
## Steps
1. Build the kit binary:
```bash
go build -o output/kit ./cmd/kit
```
2. Run the smoke test Python script against opencode/kimi-k2.5:
```bash
python3 scripts/acp_smoke_test.py
```
3. Verify the output shows:
- `session/new` returns a valid `sessionId`
- `session/prompt` streams `agent_thought_chunk` notifications (reasoning)
- `session/prompt` streams `agent_message_chunk` notifications (response)
- Final result has `stopReason: "end_turn"`
- `✓ SMOKE TEST PASSED` at the end
4. If the test fails, check:
- `output/kit` binary exists and is executable
- `OPENCODE_API_KEY` or `OPENCODE_ZEN_API_KEY` environment variable is set
- `scripts/acp_smoke_test.py` exists
- The model `opencode/kimi-k2.5` is available (`kit models opencode | grep kimi-k2.5`)
5. For testing with a different model, edit the script or set the `MODEL` variable:
```bash
MODEL=anthropic/claude-sonnet-4-5 python3 scripts/acp_smoke_test.py
```
The smoke test exercises the full ACP protocol: session lifecycle, streaming notifications, and tool-free prompt completion.
-146
View File
@@ -1,146 +0,0 @@
---
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
-30
View File
@@ -1,30 +0,0 @@
---
description: Stage, commit, and push changes with an auto-generated conventional commit message
---
Review the current git status and diff, then stage all changes, write a concise conventional commit message, commit, and push to the current branch.
## Steps
1. **Check status**: `git status` — understand what has changed
2. **Review the diff**: `git diff` (and `git diff --cached` if anything is already staged) — read the actual changes
3. **Stage everything**: `git add -A`
4. **Craft the commit message** following Conventional Commits:
- Format: `<type>(<scope>): <short summary>`
- Types: `feat`, `fix`, `refactor`, `chore`, `docs`, `test`, `perf`, `build`
- Scope: optional, the subsystem affected (e.g. `ui`, `cmd`, `config`)
- Summary: imperative mood, lowercase, no trailing period, ≤72 chars
- Body: add a blank line then bullet points for non-trivial changes
- Do **not** include "Generated by" or similar noise
5. **Commit**: `git commit -m "<message>"`
6. **Push**: `git push`
## Guidelines
- Read the actual diff — do not guess from filenames alone
- Prefer one well-scoped commit; do not split unless the changes are clearly unrelated
- Keep the subject line under 72 characters
- Use the body to explain *what* and *why*, not *how*
- If there is nothing to commit, say so and stop
$@
-47
View File
@@ -1,47 +0,0 @@
---
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
$@
-86
View File
@@ -1,86 +0,0 @@
---
description: Create a feature request using the GitHub template
---
Create a feature request for the Kit repository. The user wants to request: $@
## Feature Request Template
This prompt uses the `feature_request` GitHub template which requires:
| Field | Required | Purpose |
|-------|----------|---------|
| **Feature Description** | Yes | What should be added or changed |
| **Motivation / Use Case** | Yes | Why is this needed? What problem does it solve? |
| **Proposed Implementation** | No | How do you think this should work? |
## Steps
1. **Understand the request** from the user input: $@
- What capability is missing?
- What would the ideal behavior look like?
2. **Ask clarifying questions** if needed:
- "What problem does this solve for you?"
- "How would you expect this to work?"
- "Are there similar features in other tools you use?"
3. **Craft the title** using conventional format:
- `feat: <short description>`
- Lowercase, imperative mood, ≤72 chars
- Good examples:
- `feat: add keyboard shortcut for clearing input`
- `feat: support custom themes per extension`
- `feat: add fuzzy matching to model selector`
- Bad examples:
- `Feature request: can we have...` (too vague)
- `It would be nice if...` (not imperative)
4. **Build the body** with the template fields:
**Feature Description:**
- Clear statement of what to add/change
- Be specific about the behavior
- Include UI/UX details if relevant
**Motivation / Use Case:**
- What problem does this solve?
- Current workaround (if any) and why it's insufficient
- Who benefits from this feature?
**Proposed Implementation** (optional but helpful):
- High-level approach
- API changes if applicable
- Example usage code
5. **Create the issue**:
```bash
gh issue create --template feature_request --title "feat: ..." --body "..."
```
6. **Confirm success**:
- Show the issue URL and number
- Mention it was created with the feature_request template
## Guidelines
- Focus on the *problem* first, then the solution
- Include concrete examples of how the feature would be used
- Consider edge cases and mention them
- If proposing API changes, show before/after code
- Check if similar features exist in related tools (mention them for reference)
- Align with Kit's philosophy: TUI-first, extension-based, keyboard-driven
## Example
User: `/feature-request I want to be able to customize tool border colors dynamically`
You:
1. Title: `feat: dynamic border colors for tool results based on status`
2. Body:
- **Feature Description**: Allow `ToolRenderConfig` to accept a function that determines border color based on tool result content or status, enabling dynamic visual feedback.
- **Motivation**: When running multiple tools, it's hard to distinguish file reads (blue), shell commands (green), and errors (red) without custom colors per result.
- **Proposed Implementation**: Add `BorderColorFunc` callback that receives `(result string, isError bool)` and returns a color string.
3. Execute: `gh issue create --template feature_request --title "feat: ..." --body "..."`
4. Confirm: Created issue #43 using feature_request template
-100
View File
@@ -1,100 +0,0 @@
---
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: $@
## Issue Templates Available
This repository has structured issue templates. You MUST use the appropriate template:
| Type | Template | Use For |
|------|----------|---------|
| `bug` | `bug_report` | Something is broken, not working as expected |
| `feat` | `feature_request` | New feature, enhancement, improvement |
| `docs` | `documentation` | Missing, incorrect, or unclear documentation |
## Steps
1. **Determine the issue type** from the user input: $@
- Bug → use `--template bug_report`
- Feature → use `--template feature_request`
- Documentation → use `--template documentation`
2. **Ask clarifying questions** if critical info is missing:
- For bugs: "What were you doing when this happened?" (reproduction steps)
- For features: "What problem does this solve?" (motivation)
- For docs: "Where did you look for this information?" (location)
3. **Craft the title** using conventional format:
- `<type>: <short description>`
- Lowercase, imperative mood, ≤72 chars
- Examples:
- `fix: ToolRenderConfig BorderColor ignored during rendering`
- `feat: add keyboard shortcut for clearing input`
- `docs: clarify extension widget lifecycle`
4. **File the issue** using the template:
```bash
# For bugs
gh issue create --template bug_report --title "fix: ..." --body "..."
# For features
gh issue create --template feature_request --title "feat: ..." --body "..."
# For documentation
gh issue create --template documentation --title "docs: ..." --body "..."
```
The template will guide the user through the required fields. You need to provide:
- **Bug reports**: Description, reproduction steps, expected vs actual behavior
- **Feature requests**: Description, motivation/use case, optional proposed implementation
- **Documentation**: Description, location of docs, suggested improvement
5. **Confirm success** by showing:
- The issue URL
- The issue number
- Which template was used
## Template Field Guide
### Bug Report (`bug_report`)
Required fields in the body:
- **Bug Description** - what happened vs expected
- **Steps to Reproduce** - numbered list to recreate the bug
- **Relevant Code** - code snippets, configuration, error messages
- **Component** - which part of Kit (ui, extensions, session, etc.)
- **Version** - Kit version or commit hash
### Feature Request (`feature_request`)
Required fields in the body:
- **Feature Description** - what to add/change
- **Motivation / Use Case** - why this is needed
- **Proposed Implementation** - how it could work (optional)
### Documentation (`documentation`)
Required fields in the body:
- **Documentation Issue** - what's wrong or missing
- **Documentation Location** - file or URL where docs exist
- **Suggested Improvement** - how to fix the docs
## Guidelines
- ALWAYS use `--template <name>` instead of bare `gh issue create`
- Include file paths and line numbers when you know them
- Use triple backticks for code blocks
- Keep the body factual - avoid speculation unless in "Proposed Fix" section
- If you're unsure about technical details, say so in the issue
- For UI bugs, describe what you see vs what you expect
- For API bugs, include the relevant struct/function names
## Example Usage
User: `/file-issue The ToolRenderConfig BorderColor field is documented but never used in rendering`
You:
1. Determine this is a **bug** (documented field doesn't work)
2. Use `--template bug_report`
3. Gather: reproduction steps (register renderer with BorderColor), expected (custom color), actual (default color)
4. Create issue with title `fix: ToolRenderConfig BorderColor and Background fields are ignored`
5. Confirm: Created issue #42 using bug_report template
-61
View File
@@ -1,61 +0,0 @@
---
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
-84
View File
@@ -1,84 +0,0 @@
---
description: Scaffold a new prompt template in .kit/prompts/
---
Create a new kit prompt template. The user wants a prompt that does: $@
## What a prompt template is
A prompt template is a `.md` file in `.kit/prompts/` (project-local) or `~/.kit/prompts/` (global).
It becomes a `/slug` slash command in the kit input box — typed as `/filename` with optional arguments.
## File format
```
---
description: One-line description shown in autocomplete
---
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
- **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
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. **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
- **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
- Keep prompts action-oriented — they should tell kit *what to do*, not just *what to think about*
- 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
-70
View File
@@ -1,70 +0,0 @@
---
description: Semantic version tagging workflow - analyzes commits and tags releases
---
# Release Tagging Workflow
Tag a new version of this Go project following semantic versioning.
## Steps
1. **Fetch remote tags**: `git fetch --tags origin`
2. **Find latest version**: `git tag -l | sort -V | tail -5` to see recent tags
3. **Analyze changes since last tag**:
- `git log <latest-tag>..HEAD --oneline` - list commits
- `git diff <latest-tag>..HEAD --stat` - see file stats
- `git diff <latest-tag>..HEAD --name-only` - see changed files
4. **Determine version bump** (Semantic Versioning):
- **MAJOR (X.0.0)**: Breaking API changes, incompatible modifications
- **MINOR (0.X.0)**: New features, backward-compatible additions
- **PATCH (0.0.X)**: Bug fixes, backward-compatible fixes
Look for indicators:
- `feat:` or `feature:` commits → MINOR
- `fix:` or `bugfix:` commits → PATCH
- `breaking:` or `BREAKING CHANGE:` → MAJOR
- Breaking API changes in `pkg/` or public interfaces → MAJOR
- New commands, flags, or features → MINOR
- Documentation-only changes → PATCH (or skip)
5. **Calculate new version**: Increment appropriate segment, reset lower segments to 0
6. **Draft tag message**:
- Summarize key changes from commits
- Group by type (Features, Fixes, Breaking Changes)
- Keep concise but informative
7. **Create annotated tag**: `git tag -a vX.Y.Z -m "vX.Y.Z - <summary>\n\n<detailed list>"`
8. **Push tag**: `git push origin vX.Y.Z`
## Guidelines
- Always fetch remote tags first to avoid conflicts
- Use annotated tags (`-a`) with descriptive messages
- Follow semver strictly - when in doubt, prefer conservative bump (patch over minor)
- For Go projects, changes to `pkg/` or exported APIs warrant careful version consideration
- If no changes since last tag, suggest skipping the release
- Include commit summaries in the tag message body
## Example Tag Message Format
```
v0.30.1 - Bug fixes for model handling and UI improvements
Fixes:
- Properly handle think tags from Qwen/DeepSeek models
- Handle custom provider model persistence and bare model names
Improvements:
- UI style refactoring and cleanup
```
Wait for the user to confirm the version and message before executing tag commands.
---
$@
-52
View File
@@ -1,52 +0,0 @@
---
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
@@ -0,0 +1,8 @@
{
"$schema": "https://opencode.ai/config.json",
"permission": {
"external_directory": {
"~/go/**": "deny"
}
}
}
+19 -45
View File
@@ -1,3 +1,22 @@
<!-- OPENSPEC:START -->
# OpenSpec Instructions
These instructions are for AI assistants working in this project.
Always open `@/openspec/AGENTS.md` when the request:
- Mentions planning or proposals (words like proposal, spec, change, plan)
- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work
- Sounds ambiguous and you need the authoritative spec before coding
Use `@/openspec/AGENTS.md` to learn:
- How to create and apply change proposals
- Spec format and conventions
- Project structure and guidelines
Keep this managed block so 'openspec update' can refresh the instructions.
<!-- OPENSPEC:END -->
# KIT Agent Guidelines
## Build/Test Commands
@@ -23,33 +42,6 @@
- **Extension system** (`internal/extensions/`): Yaegi-interpreted Go, 13 lifecycle events, custom tools/commands/widgets/overlays/editor interceptors
- **TUI** (`internal/ui/`): Bubble Tea v2 parent-child model (`AppModel``InputComponent`, `StreamComponent`, etc.)
- **Decoupling pattern**: `cmd/root.go` has converter functions (e.g. `widgetProviderForUI()`) that bridge `internal/extensions/` types to `internal/ui/` types — the UI never imports extensions directly
- **Public SDK** (`pkg/kit/`): The public-facing Go SDK for embedding Kit as a library. See rules below.
## Public SDK (`pkg/kit/`) Rules
`pkg/kit/` is the **public API surface** consumed by external Go developers. All exported symbols, types, function names, and godoc comments in this package are part of the SDK contract.
### No Dependency Name Leakage
Internal dependency names (e.g. `charm.land/fantasy`, library-specific jargon) **must not** appear in:
- **Exported function/method names** — use generic terms (`LLM`, `Provider`, `Message`) instead of library names
- **Exported type names** — type aliases should use domain names (e.g. `LLMMessage`, not `FantasyMessage`)
- **Godoc comments** on exported symbols — these are visible in `go doc` output and pkg.go.dev
- **Struct field names and tags** on exported types
Using dependency types directly in **function bodies** (private implementation) is fine — that's invisible to SDK consumers.
### Naming Conventions for SDK Symbols
- Type aliases re-exporting dependency types: use `LLM*` prefix (e.g. `LLMMessage`, `LLMUsage`, `LLMResponse`)
- Conversion helpers: use `ConvertToLLM*` / `ConvertFromLLM*` (not the dependency name)
- Provider queries: use `GetLLMProviders` (not `GetFantasyProviders`)
- When wrapping internal methods, the `pkg/kit/` name should be dependency-agnostic even if the `internal/` method still uses the old name
### Deprecation Pattern
When renaming a public SDK symbol, keep the old name as a deprecated wrapper for one release cycle:
```go
// Deprecated: Use NewName instead.
func OldName() { return NewName() }
```
## Key Patterns
@@ -100,21 +92,3 @@ Positional args are the prompt. `@file` args attach file content. Key flags: `--
- Never guess or manually search the filesystem for external projects
- Example: `btca ask -r https://github.com/user/repo -q "How does X work?"`
- See `.agents/skills/btca-cli/SKILL.md` for full btca usage
## BTCA Configured Resources
The following external repositories are configured in `btca.config.jsonc` for research:
- bubbletea
- lipgloss
- bubbles
- glamour
- fantasy
- catwalk
- crush
- pi
- iteratr
- yaegi
- acp-go-sdk
- opencode
- herald
- herald-md
+60 -235
View File
@@ -18,8 +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 (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`
- **Built-in Core Tools**: bash, read, write, edit, grep, find, ls, spawn_subagent - no MCP overhead
- **MCP Integration**: Connect external MCP servers for expanded capabilities
- **Extension System**: Write custom tools, commands, widgets, and UI modifications in Go
- **Theming**: 22 built-in color themes (KITT, Catppuccin, Dracula, Nord, etc.) with runtime switching, persistence, and custom theme files
@@ -29,7 +28,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 with full agent lifecycle events (30+ event types) and behavior-modifying hooks
- **Go SDK**: Embed Kit in your own applications
## Installation
@@ -126,13 +125,8 @@ model: anthropic/claude-sonnet-latest
max-tokens: 4096
temperature: 0.7
stream: true
thinking-level: off # off, none, minimal, low, medium, high
```
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
@@ -157,16 +151,6 @@ 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
@@ -202,14 +186,12 @@ mcpServers:
--no-prompt-templates Disable prompt template loading
# Generation parameters
--max-tokens Maximum tokens in response (default: 8192, auto-raised up to 32768 for models with larger known output limits)
--max-tokens Maximum tokens in response (default: 4096)
--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, none, minimal, low, medium, high (default: off)
--thinking-level Extended thinking level: off, minimal, low, medium, high (default: off)
# System
--config Config file path (default: ~/.kit.yml)
@@ -221,14 +203,13 @@ mcpServers:
```bash
# Authentication (for OAuth-enabled providers)
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
kit auth login [provider] # Start OAuth flow (e.g., anthropic)
kit auth logout [provider] # Remove credentials for provider
kit auth status # Check authentication status
# Model database
kit models [provider] # List available models (optionally filter by provider)
kit models --all # Show all providers (not just LLM-compatible)
kit models --all # Show all providers (not just Fantasy-compatible)
kit update-models [source] # Update model database (from models.dev, URL, file, or 'embedded')
# Extension management
@@ -306,7 +287,7 @@ kit -e examples/extensions/minimal.go
### Extension Capabilities
**Lifecycle Events**: OnSessionStart, OnSessionShutdown, OnBeforeAgentStart, OnAgentStart, OnAgentEnd, OnToolCall, OnToolCallInputStart, OnToolCallInputDelta, OnToolCallInputEnd, OnToolExecutionStart, OnToolOutput, OnToolExecutionEnd, OnToolResult, OnInput, OnMessageStart, OnMessageUpdate, OnMessageEnd, OnModelChange, OnContextPrepare, OnBeforeFork, OnBeforeSessionSwitch, OnBeforeCompact, OnCustomEvent, OnSubagentStart, OnSubagentChunk, OnSubagentEnd
**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
**Custom Components**:
- **Tools**: Add new tools the LLM can invoke
@@ -326,50 +307,41 @@ kit -e examples/extensions/minimal.go
- **Themes**: Register and switch color themes via `RegisterTheme`, `SetTheme`, `ListThemes`
- **Custom Events**: Inter-extension communication via `EmitCustomEvent`
**Bridged SDK APIs** (NEW): Extensions can now access internal SDK capabilities:
- **Tree Navigation**: Navigate conversation history (`GetTreeNode`, `GetCurrentBranch`, `NavigateTo`), summarize branches (`SummarizeBranch`), and implement fresh context loops (`CollapseBranch`)
- **Skill Loading**: Dynamically load and inject skills at runtime (`LoadSkill`, `DiscoverSkills`, `InjectSkillAsContext`)
- **Template Parsing**: Parse and render templates with `{{variables}}` (`ParseTemplate`, `RenderTemplate`), parse CLI-style arguments (`ParseArguments`, `SimpleParseArguments`), and evaluate model conditionals (`EvaluateModelConditional`, `RenderWithModelConditionals`)
- **Model Resolution**: Resolve model fallback chains (`ResolveModelChain`), query model capabilities (`GetModelCapabilities`, `CheckModelAvailable`), and extract provider/model ID (`GetCurrentProvider`, `GetCurrentModelID`)
### Extension Examples
See the `examples/extensions/` directory:
- [`minimal.go`](examples/extensions/minimal.go) - Clean UI with custom footer
- [`auto-commit.go`](examples/extensions/auto-commit.go) - Auto-commit on shutdown
- [`bookmark.go`](examples/extensions/bookmark.go) - Bookmark conversations
- [`branded-output.go`](examples/extensions/branded-output.go) - Branded output rendering
- [`bridge-demo.go`](examples/extensions/bridge_demo.go) - Bridged SDK API demo (tree navigation, skills, templates, model resolution)
- [`compact-notify.go`](examples/extensions/compact-notify.go) - Notification on compaction
- [`confirm-destructive.go`](examples/extensions/confirm-destructive.go) - Confirm destructive operations
- [`context-inject.go`](examples/extensions/context-inject.go) - Inject context into conversations
- [`conversation-manager.go`](examples/extensions/conversation-manager.go) - **NEW** Tree navigation, branch summarization, and fresh context loops
- [`custom-editor-demo.go`](examples/extensions/custom-editor-demo.go) - Vim-like modal editor
- [`dev-reload.go`](examples/extensions/dev-reload.go) - Development live-reload
- [`header-footer-demo.go`](examples/extensions/header-footer-demo.go) - Custom headers and footers
- [`inline-bash.go`](examples/extensions/inline-bash.go) - Inline bash execution
- [`interactive-shell.go`](examples/extensions/interactive-shell.go) - Interactive shell integration
- [`kit-kit.go`](examples/extensions/kit-kit.go) - Kit-in-Kit (sub-agent spawning)
- [`lsp-diagnostics.go`](examples/extensions/lsp-diagnostics.go) - LSP diagnostic integration
- [`notify.go`](examples/extensions/notify.go) - Desktop notifications
- [`overlay-demo.go`](examples/extensions/overlay-demo.go) - Modal dialogs
- [`permission-gate.go`](examples/extensions/permission-gate.go) - Permission gating for tools
- [`pirate.go`](examples/extensions/pirate.go) - Pirate-themed personality
- [`plan-mode.go`](examples/extensions/plan-mode.go) - Read-only planning mode
- [`project-rules.go`](examples/extensions/project-rules.go) - Project-specific rules
- [`prompt-demo.go`](examples/extensions/prompt-demo.go) - Interactive prompts (select/confirm/input)
- [`prompt-templates.go`](examples/extensions/prompt-templates.go) - **NEW** Frontmatter-driven templates with model switching and skill injection
- [`protected-paths.go`](examples/extensions/protected-paths.go) - Path protection for sensitive files
- [`subagent-widget.go`](examples/extensions/subagent-widget.go) - Multi-agent orchestration with status widget
- [`subagent-test.go`](examples/extensions/subagent-test.go) - Subagent testing utilities
- [`summarize.go`](examples/extensions/summarize.go) - Conversation summarization
- [`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
- [`widget-status.go`](examples/extensions/widget-status.go) - Persistent status widgets
- `minimal.go` - Clean UI with custom footer
- `auto-commit.go` - Auto-commit on shutdown
- `bookmark.go` - Bookmark conversations
- `branded-output.go` - Branded output rendering
- `compact-notify.go` - Notification on compaction
- `confirm-destructive.go` - Confirm destructive operations
- `context-inject.go` - Inject context into conversations
- `custom-editor-demo.go` - Vim-like modal editor
- `dev-reload.go` - Development live-reload
- `header-footer-demo.go` - Custom headers and footers
- `inline-bash.go` - Inline bash execution
- `interactive-shell.go` - Interactive shell integration
- `kit-kit.go` - Kit-in-Kit (sub-agent spawning)
- `lsp-diagnostics.go` - LSP diagnostic integration
- `notify.go` - Desktop notifications
- `overlay-demo.go` - Modal dialogs
- `permission-gate.go` - Permission gating for tools
- `pirate.go` - Pirate-themed personality
- `plan-mode.go` - Read-only planning mode
- `project-rules.go` - Project-specific rules
- `prompt-demo.go` - Interactive prompts (select/confirm/input)
- `protected-paths.go` - Path protection for sensitive files
- `subagent-widget.go` - Multi-agent orchestration with status widget
- `subagent-test.go` - Subagent testing utilities
- `summarize.go` - Conversation summarization
- `tool-logger.go` - Log all tool calls
- `neon-theme.go` - Custom theme registration and switching
- `tool-renderer-demo.go` - Custom tool call rendering
- `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.
Also see `.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.
### Loading Extensions
@@ -426,7 +398,7 @@ func TestMyExtension(t *testing.T) {
- `AssertPrinted()`, `AssertPrintedContains()` — Verify output
- `AssertToolRegistered()`, `AssertCommandRegistered()` — Verify registration
See [`examples/extensions/tool-logger_test.go`](examples/extensions/tool-logger_test.go) for a complete example with 14 test cases covering tool calls, input handling, and session lifecycle.
See `examples/extensions/tool-logger_test.go` for a complete example with 14 test cases covering tool calls, input handling, and session lifecycle.
### Prompt Templates
@@ -448,13 +420,10 @@ Focus on $1 specifically.
**Argument placeholders:**
- `$1`, `$2`, etc. — Individual arguments
- `$@` or `$ARGUMENTS` — All arguments (zero or more)
- `$+` — All arguments (one or more required; error if none given)
- `$@` or `$ARGUMENTS` — All arguments
- `${@:2}` — Arguments from position 2 onwards
- `${@:1:3}` — 3 arguments starting at position 1
Placeholders inside fenced code blocks (```) and inline code spans are ignored.
Disable templates with `--no-prompt-templates` or load a specific template with `--prompt-template <name>`.
## Session Management
@@ -500,18 +469,9 @@ During an interactive session, use these slash commands:
| `/import <path>` | Import and switch to a session from a JSONL file |
| `/share` | Upload session to GitHub Gist and get a shareable viewer URL |
| `/tree` | Navigate the session tree |
| `/fork` | Fork to new session from an earlier message |
| `/fork` | Branch from an earlier message |
| `/new` | Start a fresh session |
### Keyboard Shortcuts
| Shortcut | Description |
|----------|-------------|
| `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) |
| `` / `` | Navigate prompt history |
## Go SDK
Embed Kit in your Go applications:
@@ -534,7 +494,7 @@ func main() {
if err != nil {
log.Fatal(err)
}
defer func() { _ = host.Close() }()
defer host.Close()
// Send a prompt
response, err := host.Prompt(ctx, "What is 2+2?")
@@ -557,32 +517,13 @@ host, err := kit.New(ctx, &kit.Options{
Streaming: true,
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
NoSession: true, // Ephemeral mode
// 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)
// Configuration
SkipConfig: true, // Skip .kit.yml files (viper defaults + env vars still apply)
ExtraTools: []kit.Tool{...}, // Additional tools alongside defaults
// Compaction
AutoCompact: true, // Auto-compact near context limit
@@ -591,142 +532,26 @@ 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.
### 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:
```go
type SearchInput struct {
Query string `json:"query" description:"Search query"`
}
searchTool := kit.NewTool("search", "Search the codebase",
func(ctx context.Context, input SearchInput) (kit.ToolOutput, error) {
return kit.TextResult("Found: ..."), nil
},
)
host, _ := kit.New(ctx, &kit.Options{
ExtraTools: []kit.Tool{searchTool}, // adds alongside built-in tools
})
```
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
```go
unsub := host.OnToolCall(func(e kit.ToolCallEvent) {
println("Calling tool:", e.ToolName)
})
defer unsub()
unsub2 := host.OnToolResult(func(e kit.ToolResultEvent) {
if e.IsError {
println("Tool failed:", e.ToolName)
}
})
defer unsub2()
unsub3 := host.OnMessageUpdate(func(e kit.MessageUpdateEvent) {
print(e.Chunk)
})
defer unsub3()
response, err := host.Prompt(
response, err := host.PromptWithCallbacks(
ctx,
"List files in current directory",
func(name, args string) {
// Tool call started
println("Calling tool:", name)
},
func(name, args, result string, isError bool) {
// Tool call completed
if isError {
println("Tool failed:", name)
}
},
func(chunk string) {
// Streaming text chunk
print(chunk)
},
)
```
@@ -890,7 +715,7 @@ Use `custom/custom` when pointing Kit at any OpenAI-compatible endpoint with `--
kit --provider-url "http://localhost:8080/v1" "Hello"
```
This automatically defaults to `custom/custom` without needing to specify a model. The custom provider routes through the `openaicompat` provider and supports:
This automatically defaults to `custom/custom` without needing to specify a model. The custom provider routes through fantasy's `openaicompat` provider and supports:
- Zero cost tracking (input/output = 0)
- 262K context window, 65K output limit
-12
View File
@@ -76,18 +76,6 @@
"name": "opencode",
"url": "https://github.com/anomalyco/opencode",
"branch": "dev"
},
{
"type": "git",
"name": "herald",
"url": "https://github.com/indaco/herald",
"branch": "main"
},
{
"type": "git",
"name": "herald-md",
"url": "https://github.com/indaco/herald-md",
"branch": "main"
}
],
"model": "claude-haiku-4-5",
-3
View File
@@ -11,7 +11,6 @@ import (
"os/signal"
"syscall"
"github.com/charmbracelet/log"
acp "github.com/coder/acp-go-sdk"
"github.com/mark3labs/kit/internal/acpserver"
@@ -55,8 +54,6 @@ func runACP(cmd *cobra.Command, _ []string) error {
conn.SetLogger(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelDebug,
})))
// Also set charmbracelet/log level for acpserver package logging
log.SetLevel(log.DebugLevel)
}
// Wait for either the client to disconnect or a signal.
+4 -64
View File
@@ -11,7 +11,6 @@ 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"
)
@@ -55,13 +54,9 @@ Available providers:
- anthropic: Anthropic Claude API (OAuth)
- openai: OpenAI ChatGPT Plus/Pro (Codex OAuth)
Flags:
--set-default Set this provider's default model as the system default
Examples:
Example:
kit auth login anthropic
kit auth login openai
kit auth login openai --set-default`,
kit auth login openai`,
Args: cobra.ExactArgs(1),
RunE: runAuthLogin,
}
@@ -104,43 +99,10 @@ 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",
}
// 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")
}
func runAuthLogin(cmd *cobra.Command, args []string) error {
@@ -326,17 +288,6 @@ 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
}
@@ -503,17 +454,6 @@ 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
}
@@ -564,13 +504,13 @@ func startOpenAICallbackServer(expectedState string) (*callbackServer, error) {
}
// Return success page
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Content-Type", "text/html")
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>&#10003; Authentication Successful</h1>
<h1> Authentication Successful</h1>
<p>You can close this window and return to the terminal.</p>
</body>
</html>`)
-473
View File
@@ -1,473 +0,0 @@
package cmd
import (
"context"
"fmt"
"os"
"strings"
"github.com/spf13/viper"
"golang.org/x/term"
"github.com/mark3labs/kit/internal/app"
"github.com/mark3labs/kit/internal/auth"
"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).
//
// This consolidates two near-identical 400-line literal expressions that
// previously appeared inline in runNormalMode.
func buildInteractiveExtensionContext(deps extensionContextDeps) extensions.Context {
kitInstance := deps.kitInstance
appInstance := deps.appInstance
usageTracker := deps.usageTracker
ctx := deps.ctx
return extensions.Context{
CWD: deps.cwd,
Model: deps.modelName,
Interactive: deps.interactive,
PrintBlock: func(opts extensions.PrintBlockOpts) {
appInstance.PrintBlockFromExtension(opts)
},
SendMessage: func(text string) { appInstance.Run(text) },
CancelAndSend: func(text string) { appInstance.InterruptAndSend(text) },
Abort: func() { appInstance.Abort() },
IsIdle: func() bool { return !appInstance.IsBusy() },
Compact: func(cfg extensions.CompactConfig) error {
return appInstance.CompactAsync(cfg.CustomInstructions, cfg.OnComplete, cfg.OnError)
},
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)
},
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,
}
},
Exit: func() { appInstance.QuitFromExtension() },
SetWidget: func(config extensions.WidgetConfig) {
kitInstance.Extensions().SetWidget(config)
go appInstance.NotifyWidgetUpdate()
},
RemoveWidget: func(id string) {
kitInstance.Extensions().RemoveWidget(id)
go appInstance.NotifyWidgetUpdate()
},
SetHeader: func(config extensions.HeaderFooterConfig) {
kitInstance.Extensions().SetHeader(config)
go appInstance.NotifyWidgetUpdate()
},
RemoveHeader: func() {
kitInstance.Extensions().RemoveHeader()
go appInstance.NotifyWidgetUpdate()
},
SetFooter: func(config extensions.HeaderFooterConfig) {
kitInstance.Extensions().SetFooter(config)
go appInstance.NotifyWidgetUpdate()
},
RemoveFooter: func() {
kitInstance.Extensions().RemoveFooter()
go appInstance.NotifyWidgetUpdate()
},
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}
},
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}
},
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}
},
SetUIVisibility: func(v extensions.UIVisibility) {
kitInstance.Extensions().SetUIVisibility(v)
go appInstance.NotifyWidgetUpdate()
},
GetContextStats: func() extensions.ContextStats {
s := kitInstance.GetContextStats()
return extensions.ContextStats{
EstimatedTokens: s.EstimatedTokens,
ContextLimit: s.ContextLimit,
UsagePercent: s.UsagePercent,
MessageCount: s.MessageCount,
}
},
SetEditor: func(config extensions.EditorConfig) {
kitInstance.Extensions().SetEditor(config)
// 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.
go appInstance.NotifyWidgetUpdate()
},
ResetEditor: func() {
kitInstance.Extensions().ResetEditor()
go appInstance.NotifyWidgetUpdate()
},
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)
},
SetEditorText: func(text string) {
appInstance.SetEditorTextFromExtension(text)
},
SetStatus: func(key string, text string, priority int) {
kitInstance.Extensions().SetStatus(extensions.StatusBarEntry{
Key: key,
Text: text,
Priority: priority,
})
go appInstance.NotifyWidgetUpdate()
},
RemoveStatus: func(key string) {
kitInstance.Extensions().RemoveStatus(key)
go appInstance.NotifyWidgetUpdate()
},
GetOption: func(name string) string {
return kitInstance.Extensions().GetOption(name)
},
SetOption: func(name string, value string) {
kitInstance.Extensions().SetOption(name, value)
},
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.
if usageTracker != nil {
newProvider, newModel, _ := models.ParseModelString(modelString)
if newProvider != "unknown" && newModel != "unknown" && newProvider != "ollama" {
registry := models.GetGlobalRegistry()
if modelInfo := registry.LookupModel(newProvider, newModel); modelInfo != nil {
// Check OAuth status for Anthropic models
isOAuth := false
if newProvider == "anthropic" {
_, source, err := auth.GetAnthropicAPIKey(viper.GetString("provider-api-key"))
if err == nil && strings.HasPrefix(source, "stored OAuth") {
isOAuth = true
}
}
usageTracker.UpdateModelInfo(modelInfo, newProvider, isOAuth)
}
}
}
return nil
},
GetAvailableModels: func() []extensions.ModelInfoEntry {
return kitInstance.GetAvailableModels()
},
EmitCustomEvent: func(name string, data string) {
kitInstance.Extensions().EmitCustomEvent(name, data)
},
Complete: func(req extensions.CompleteRequest) (extensions.CompleteResponse, error) {
return kitInstance.ExecuteCompletion(context.Background(), req)
},
SuspendTUI: func(callback func()) error {
return appInstance.SuspendTUI(callback)
},
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)
},
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
},
GetAllTools: func() []extensions.ToolInfo {
return kitInstance.Extensions().GetToolInfos()
},
SetActiveTools: func(names []string) {
kitInstance.Extensions().SetActiveTools(names)
},
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),
)
},
SetTheme: func(name string) error {
return ui.ApplyTheme(name)
},
ListThemes: func() []string {
return ui.ListThemes()
},
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,
}
},
SpawnSubagent: func(config extensions.SubagentConfig) (*extensions.SubagentHandle, *extensions.SubagentResult, error) {
return extbridge.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
// -------------------------------------------------------------------
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}
},
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)
},
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 ""
},
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)
},
}
}
+1 -1
View File
@@ -55,7 +55,7 @@ func printAllProviders(showAll bool) error {
if showAll {
providerIDs = kit.GetSupportedProviders()
} else {
providerIDs = kit.GetLLMProviders()
providerIDs = kit.GetFantasyProviders()
}
sort.Strings(providerIDs)
+513 -519
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -41,6 +41,7 @@ func BuildAppOptions(mcpConfig *config.Config, modelName string, serverNames, to
StreamingEnabled: viper.GetBool("stream"),
Quiet: quietFlag,
Debug: viper.GetBool("debug"),
CompactMode: viper.GetBool("compact"),
}
}
@@ -130,6 +131,7 @@ func SetupCLIForNonInteractive(k *kit.Kit) (*ui.CLI, error) {
Agent: agentAdapter,
ModelString: viper.GetString("model"),
Debug: viper.GetBool("debug"),
Compact: viper.GetBool("compact"),
Quiet: quietFlag,
ShowDebug: false,
ProviderAPIKey: viper.GetString("provider-api-key"),
@@ -1,27 +0,0 @@
package main
import (
"testing"
"github.com/mark3labs/kit/pkg/extensions/test"
)
// TestAllExtensions_Load is a smoke test that verifies every single-file
// example extension in this directory can be loaded by the Yaegi interpreter
// without errors. This catches syntax errors, missing symbols, bad imports,
// and Init signature mismatches.
func TestAllExtensions_Load(t *testing.T) {
files := extensionFiles(t)
for _, file := range files {
t.Run(file, func(t *testing.T) {
harness := test.New(t)
ext := harness.LoadFile(file)
if ext == nil {
t.Fatalf("%s: extension should not be nil after loading", file)
}
})
}
t.Logf("successfully loaded %d extensions", len(files))
}
@@ -1,253 +0,0 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/mark3labs/kit/internal/extensions"
"github.com/mark3labs/kit/pkg/extensions/test"
)
// extensionFiles returns all single-file extensions in the current directory.
// It skips test files, the test template, and files without an Init function.
func extensionFiles(t *testing.T) []string {
t.Helper()
skip := map[string]bool{
"extension_test_template.go": true,
}
entries, err := os.ReadDir(".")
if err != nil {
t.Fatalf("failed to read directory: %v", err)
}
var files []string
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() || filepath.Ext(name) != ".go" {
continue
}
if strings.HasSuffix(name, "_test.go") || skip[name] {
continue
}
src, err := os.ReadFile(name)
if err != nil {
t.Fatalf("failed to read %s: %v", name, err)
}
if !strings.Contains(string(src), "func Init(") {
continue
}
files = append(files, name)
}
if len(files) == 0 {
t.Fatal("no extensions found — check the directory")
}
return files
}
// TestAllExtensions_Lifecycle verifies that every extension survives a full
// SessionStart → SessionShutdown round-trip without errors.
func TestAllExtensions_Lifecycle(t *testing.T) {
for _, file := range extensionFiles(t) {
t.Run(file, func(t *testing.T) {
harness := test.New(t)
harness.LoadFile(file)
_, err := harness.Emit(extensions.SessionStartEvent{
SessionID: "smoke-test-session",
})
if err != nil {
t.Fatalf("SessionStart error: %v", err)
}
_, err = harness.Emit(extensions.SessionShutdownEvent{})
if err != nil {
t.Fatalf("SessionShutdown error: %v", err)
}
})
}
}
// TestAllExtensions_CommandSanity checks that every registered command has
// a non-empty name, a non-empty description, no spaces in the name, no
// leading slash, a non-nil Execute function, and no duplicate names.
func TestAllExtensions_CommandSanity(t *testing.T) {
for _, file := range extensionFiles(t) {
t.Run(file, func(t *testing.T) {
harness := test.New(t)
harness.LoadFile(file)
cmds := harness.RegisteredCommands()
seen := make(map[string]bool)
for _, cmd := range cmds {
if cmd.Name == "" {
t.Error("command has empty name")
}
if strings.Contains(cmd.Name, " ") {
t.Errorf("command %q contains spaces", cmd.Name)
}
if strings.HasPrefix(cmd.Name, "/") {
t.Errorf("command %q has leading slash (framework adds it)", cmd.Name)
}
if cmd.Description == "" {
t.Errorf("command %q has empty description", cmd.Name)
}
if cmd.Execute == nil {
t.Errorf("command %q has nil Execute function", cmd.Name)
}
if seen[cmd.Name] {
t.Errorf("duplicate command name %q", cmd.Name)
}
seen[cmd.Name] = true
}
})
}
}
// TestAllExtensions_ToolSanity checks that every registered tool has a
// non-empty name, a non-empty description, at least one executor, valid
// JSON in its Parameters field, and no duplicate names.
func TestAllExtensions_ToolSanity(t *testing.T) {
for _, file := range extensionFiles(t) {
t.Run(file, func(t *testing.T) {
harness := test.New(t)
harness.LoadFile(file)
tools := harness.RegisteredTools()
seen := make(map[string]bool)
for _, tool := range tools {
if tool.Name == "" {
t.Error("tool has empty name")
}
if tool.Description == "" {
t.Errorf("tool %q has empty description", tool.Name)
}
if tool.Execute == nil && tool.ExecuteWithContext == nil {
t.Errorf("tool %q has no executor (both Execute and ExecuteWithContext are nil)", tool.Name)
}
if tool.Parameters != "" && !json.Valid([]byte(tool.Parameters)) {
t.Errorf("tool %q has invalid JSON in Parameters: %s", tool.Name, tool.Parameters)
}
if seen[tool.Name] {
t.Errorf("duplicate tool name %q", tool.Name)
}
seen[tool.Name] = true
}
})
}
}
// TestAllExtensions_ZeroValueEvents fires every event type (as zero-value
// structs) at each extension and verifies no errors are returned. Extensions
// should be resilient to events they don't handle and to events with empty
// fields.
func TestAllExtensions_ZeroValueEvents(t *testing.T) {
// Build the set of zero-value events for every event type.
zeroEvents := []extensions.Event{
extensions.ToolCallEvent{},
extensions.ToolExecutionStartEvent{},
extensions.ToolExecutionEndEvent{},
extensions.ToolOutputEvent{},
extensions.ToolResultEvent{},
extensions.InputEvent{},
extensions.BeforeAgentStartEvent{},
extensions.AgentStartEvent{},
extensions.AgentEndEvent{},
extensions.MessageStartEvent{},
extensions.MessageUpdateEvent{},
extensions.MessageEndEvent{},
extensions.SessionStartEvent{},
extensions.SessionShutdownEvent{},
extensions.ModelChangeEvent{},
extensions.ContextPrepareEvent{},
extensions.BeforeForkEvent{},
extensions.BeforeSessionSwitchEvent{},
extensions.BeforeCompactEvent{},
extensions.SubagentStartEvent{},
extensions.SubagentChunkEvent{},
extensions.SubagentEndEvent{},
}
for _, file := range extensionFiles(t) {
t.Run(file, func(t *testing.T) {
harness := test.New(t)
harness.LoadFile(file)
for _, ev := range zeroEvents {
_, err := harness.Emit(ev)
if err != nil {
t.Errorf("event %T returned error: %v", ev, err)
}
}
})
}
}
// TestAllExtensions_WidgetSanity emits SessionStart and then checks that
// any widgets set during initialization have non-empty IDs and valid
// placements.
func TestAllExtensions_WidgetSanity(t *testing.T) {
validPlacements := map[extensions.WidgetPlacement]bool{
"above": true,
"below": true,
}
for _, file := range extensionFiles(t) {
t.Run(file, func(t *testing.T) {
harness := test.New(t)
harness.LoadFile(file)
// Trigger SessionStart so extensions that set widgets on init do so.
_, _ = harness.Emit(extensions.SessionStartEvent{
SessionID: "widget-sanity-test",
})
// Widgets is an exported field on MockContext; reads are safe
// here because Emit returned synchronously.
for id, w := range harness.Context().Widgets {
if w.ID == "" {
t.Errorf("widget stored with key %q has empty ID", id)
}
if w.ID != id {
t.Errorf("widget key %q doesn't match widget ID %q", id, w.ID)
}
if !validPlacements[w.Placement] {
t.Errorf("widget %q has invalid placement %q (want \"above\" or \"below\")", id, w.Placement)
}
}
})
}
}
// TestAllExtensions_IdempotentLifecycle verifies that receiving SessionStart
// twice and SessionShutdown twice doesn't cause errors — extensions should
// be defensive about repeated lifecycle events.
func TestAllExtensions_IdempotentLifecycle(t *testing.T) {
for _, file := range extensionFiles(t) {
t.Run(file, func(t *testing.T) {
harness := test.New(t)
harness.LoadFile(file)
for i := range 2 {
_, err := harness.Emit(extensions.SessionStartEvent{
SessionID: "idempotent-test",
})
if err != nil {
t.Fatalf("SessionStart #%d error: %v", i+1, err)
}
}
for i := range 2 {
_, err := harness.Emit(extensions.SessionShutdownEvent{})
if err != nil {
t.Fatalf("SessionShutdown #%d error: %v", i+1, err)
}
}
})
}
}
-170
View File
@@ -1,170 +0,0 @@
//go:build ignore
// bridge_demo.go - Demonstrates the new bridged SDK APIs for extensions.
// This extension showcases tree navigation, skill loading, template parsing,
// and model resolution capabilities.
package main
import (
"encoding/json"
"fmt"
"strings"
"kit/ext"
)
var (
discoveredSkills []ext.Skill
currentBranch []ext.TreeNode
)
func Init(api ext.API) {
// Register /tree-info command to demonstrate tree navigation
api.RegisterCommand(ext.CommandDef{
Name: "tree-info",
Description: "Show current conversation tree information",
Execute: func(args string, ctx ext.Context) (string, error) {
branch := ctx.GetCurrentBranch()
info := fmt.Sprintf("Current branch has %d nodes:\n", len(branch))
for i, node := range branch {
info += fmt.Sprintf(" [%d] %s (%s): %s...\n", i, node.Type, node.ID[:8], truncate(node.Content, 40))
}
ctx.PrintInfo(info)
return "", nil
},
})
// Register /discover-skills command
api.RegisterCommand(ext.CommandDef{
Name: "discover-skills",
Description: "Discover and list available skills",
Execute: func(args string, ctx ext.Context) (string, error) {
result := ctx.DiscoverSkills()
if result.Error != "" {
return "", fmt.Errorf("discovery failed: %s", result.Error)
}
discoveredSkills = result.Skills
info := fmt.Sprintf("Discovered %d skills:\n", len(result.Skills))
for _, s := range result.Skills {
info += fmt.Sprintf(" - %s: %s\n", s.Name, s.Description)
}
ctx.PrintInfo(info)
return "", nil
},
})
// Register /parse-template command
api.RegisterCommand(ext.CommandDef{
Name: "parse-template",
Description: "Parse a template and show extracted variables",
Execute: func(args string, ctx ext.Context) (string, error) {
if args == "" {
args = "Hello {{name}}, welcome to {{place}}!"
}
tpl := ctx.ParseTemplate("demo", args)
info := fmt.Sprintf("Template: %s\nVariables: %v", tpl.Content, tpl.Variables)
ctx.PrintInfo(info)
return "", nil
},
})
// Register /render-template command
api.RegisterCommand(ext.CommandDef{
Name: "render-template",
Description: "Render a template with variables (usage: /render-template name=John place=Kit)",
Execute: func(args string, ctx ext.Context) (string, error) {
tpl := ctx.ParseTemplate("demo", "Hello {{name}}, welcome to {{place}}!")
vars := ctx.ParseArguments(args, ext.ArgumentPattern{
Flags: map[string]string{"name": "name", "place": "place"},
})
rendered := ctx.RenderTemplate(tpl, vars.Vars)
ctx.PrintInfo("Rendered: " + rendered)
return "", nil
},
})
// Register /check-model command
api.RegisterCommand(ext.CommandDef{
Name: "check-model",
Description: "Check model capabilities and availability",
Execute: func(args string, ctx ext.Context) (string, error) {
model := args
if model == "" {
model = ctx.Model
}
available := ctx.CheckModelAvailable(model)
caps, err := ctx.GetModelCapabilities(model)
info := fmt.Sprintf("Model: %s\n", model)
info += fmt.Sprintf("Available: %v\n", available)
if err == "" {
info += fmt.Sprintf("Provider: %s\n", caps.Provider)
info += fmt.Sprintf("Context Limit: %d\n", caps.ContextLimit)
info += fmt.Sprintf("Reasoning: %v\n", caps.Reasoning)
} else {
info += fmt.Sprintf("Error: %s\n", err)
}
ctx.PrintInfo(info)
return "", nil
},
})
// Register /resolve-chain command
api.RegisterCommand(ext.CommandDef{
Name: "resolve-chain",
Description: "Resolve a model chain (usage: /resolve-chain claude-opus,gpt-4o,claude-sonnet)",
Execute: func(args string, ctx ext.Context) (string, error) {
if args == "" {
args = "anthropic/claude-opus-4,anthropic/claude-sonnet-4,openai/gpt-4o"
}
prefs := ctx.SimpleParseArguments(args, 1)
chain := []string{}
if len(prefs) > 1 {
// Split the first arg by comma
for _, p := range strings.Split(prefs[1], ",") {
p = strings.TrimSpace(p)
if p != "" {
chain = append(chain, p)
}
}
}
result := ctx.ResolveModelChain(chain)
info, _ := json.MarshalIndent(result, "", " ")
ctx.PrintInfo("Resolution Result:\n" + string(info))
return "", nil
},
})
// Register /test-conditional command
api.RegisterCommand(ext.CommandDef{
Name: "test-conditional",
Description: "Test model conditional rendering",
Execute: func(args string, ctx ext.Context) (string, error) {
content := `<if-model is="claude-*">This is for Claude models<else>This is for other models</if-model>`
rendered := ctx.RenderWithModelConditionals(content)
ctx.PrintInfo("Input: " + content)
ctx.PrintInfo("Output: " + rendered)
ctx.PrintInfo(fmt.Sprintf("Current model matches 'claude-*': %v", ctx.EvaluateModelConditional("claude-*")))
return "", nil
},
})
// OnSessionStart: discover skills automatically
api.OnSessionStart(func(e ext.SessionStartEvent, ctx ext.Context) {
result := ctx.DiscoverSkills()
if result.Error == "" && len(result.Skills) > 0 {
discoveredSkills = result.Skills
ctx.SetStatus("bridge-demo", fmt.Sprintf("%d skills", len(result.Skills)), 50)
}
})
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max-3] + "..."
}
-406
View File
@@ -1,406 +0,0 @@
//go:build ignore
// conversation-manager.go - Advanced conversation tree navigation and management.
// This extension demonstrates:
// - Tree navigation (GetTreeNode, GetCurrentBranch, NavigateTo)
// - Branch summarization and collapsing
// - Interactive tree exploration
//
// Commands:
// /tree - Show conversation tree structure
// /branch - Show current branch path
// /goto <entry-id> - Navigate to a specific entry
// /summarize <n> - Summarize last N messages
// /fresh-context - Collapse branch and start fresh
// /loop <n> <prompt> - Execute prompt N times with fresh context each iteration
package main
import (
"fmt"
"strconv"
"strings"
"time"
"kit/ext"
)
var (
loopActive bool
loopCount int
loopCurrent int
loopPrompt string
loopStartNode string
)
func Init(api ext.API) {
// /tree - Show tree structure
api.RegisterCommand(ext.CommandDef{
Name: "tree",
Description: "Show conversation tree structure",
Execute: func(args string, ctx ext.Context) (string, error) {
showTree(ctx)
return "", nil
},
})
// /branch - Show current branch
api.RegisterCommand(ext.CommandDef{
Name: "branch",
Description: "Show current conversation branch",
Execute: func(args string, ctx ext.Context) (string, error) {
showBranch(ctx)
return "", nil
},
})
// /goto - Navigate to entry
api.RegisterCommand(ext.CommandDef{
Name: "goto",
Description: "Navigate to a specific entry ID (usage: /goto <entry-id>)",
Execute: func(args string, ctx ext.Context) (string, error) {
if args == "" {
ctx.PrintError("Usage: /goto <entry-id>")
return "", nil
}
result := ctx.NavigateTo(args)
if !result.Success {
ctx.PrintError(fmt.Sprintf("Navigation failed: %s", result.Error))
return "", nil
}
ctx.PrintInfo(fmt.Sprintf("Navigated to entry: %s", args))
// Show the node we navigated to
node := ctx.GetTreeNode(args)
if node != nil {
ctx.PrintInfo(fmt.Sprintf("Entry type: %s, Role: %s", node.Type, node.Role))
}
return "", nil
},
})
// /summarize - Summarize recent messages
api.RegisterCommand(ext.CommandDef{
Name: "summarize",
Description: "Summarize last N messages (usage: /summarize [n=5])",
Execute: func(args string, ctx ext.Context) (string, error) {
n := 5
if args != "" {
if parsed, err := strconv.Atoi(args); err == nil && parsed > 0 {
n = parsed
}
}
branch := ctx.GetCurrentBranch()
if len(branch) < 2 {
ctx.PrintError("Not enough messages to summarize")
return "", nil
}
// Find range to summarize
startIdx := len(branch) - n - 1
if startIdx < 0 {
startIdx = 0
}
endIdx := len(branch) - 1
fromID := branch[startIdx].ID
toID := branch[endIdx].ID
ctx.PrintInfo(fmt.Sprintf("Summarizing messages %d to %d...", startIdx, endIdx))
summary := ctx.SummarizeBranch(fromID, toID)
if summary == "" {
ctx.PrintError("Failed to generate summary")
return "", nil
}
ctx.PrintBlock(ext.PrintBlockOpts{
Text: summary,
BorderColor: "#89b4fa",
Subtitle: "conversation-manager · Summary",
})
return "", nil
},
})
// /fresh-context - Collapse and restart
api.RegisterCommand(ext.CommandDef{
Name: "fresh-context",
Description: "Collapse conversation to summary and start fresh",
Execute: func(args string, ctx ext.Context) (string, error) {
branch := ctx.GetCurrentBranch()
if len(branch) < 3 {
ctx.PrintError("Not enough context to collapse")
return "", nil
}
// Keep first message (system), summarize rest
fromID := branch[1].ID
toID := branch[len(branch)-1].ID
ctx.PrintInfo("Generating summary for context collapse...")
summary := ctx.SummarizeBranch(fromID, toID)
if summary == "" {
ctx.PrintError("Failed to generate summary")
return "", nil
}
// Collapse the branch
result := ctx.CollapseBranch(fromID, toID, summary)
if !result.Success {
ctx.PrintError(fmt.Sprintf("Collapse failed: %s", result.Error))
return "", nil
}
ctx.PrintInfo("Context collapsed. Starting fresh with summary.")
ctx.PrintBlock(ext.PrintBlockOpts{
Text: summary,
BorderColor: "#a6e3a1",
Subtitle: "conversation-manager · Collapsed Context",
})
// Set a widget showing we're in fresh mode
ctx.SetWidget(ext.WidgetConfig{
ID: "fresh-context",
Placement: ext.WidgetAbove,
Content: ext.WidgetContent{Text: "🌱 Fresh Context Mode - Previous conversation collapsed"},
Style: ext.WidgetStyle{BorderColor: "#a6e3a1"},
})
return "", nil
},
})
// /loop - Execute with fresh context each iteration
api.RegisterCommand(ext.CommandDef{
Name: "loop",
Description: "Execute prompt N times with fresh context (usage: /loop 5 analyze this code)",
Execute: func(args string, ctx ext.Context) (string, error) {
if loopActive {
ctx.PrintError("Loop already in progress. Wait for completion.")
return "", nil
}
// Parse arguments
parts := strings.SplitN(args, " ", 2)
if len(parts) < 2 {
ctx.PrintError("Usage: /loop <count> <prompt>")
return "", nil
}
count, err := strconv.Atoi(parts[0])
if err != nil || count <= 0 || count > 10 {
ctx.PrintError("Invalid count (must be 1-10)")
return "", nil
}
loopCount = count
loopCurrent = 0
loopPrompt = parts[1]
loopActive = true
// Store current branch position
branch := ctx.GetCurrentBranch()
if len(branch) > 0 {
loopStartNode = branch[len(branch)-1].ID
}
ctx.PrintInfo(fmt.Sprintf("Starting loop: %d iterations", loopCount))
ctx.SetWidget(ext.WidgetConfig{
ID: "loop-progress",
Placement: ext.WidgetAbove,
Content: ext.WidgetContent{Text: fmt.Sprintf("🔄 Loop: 0/%d - %s", loopCount, loopPrompt)},
Style: ext.WidgetStyle{BorderColor: "#fab387"},
})
// Start first iteration
executeLoopIteration(ctx)
return "", nil
},
})
// OnAgentEnd handles loop continuation
api.OnAgentEnd(func(e ext.AgentEndEvent, ctx ext.Context) {
if !loopActive {
return
}
loopCurrent++
if loopCurrent >= loopCount {
// Loop complete
loopActive = false
ctx.RemoveWidget("loop-progress")
ctx.PrintInfo(fmt.Sprintf("✅ Loop complete: %d/%d iterations", loopCurrent, loopCount))
// Show final summary
branch := ctx.GetCurrentBranch()
if len(branch) > 0 && loopStartNode != "" {
summary := ctx.SummarizeBranch(loopStartNode, branch[len(branch)-1].ID)
if summary != "" {
ctx.PrintBlock(ext.PrintBlockOpts{
Text: summary,
BorderColor: "#a6e3a1",
Subtitle: "conversation-manager · Loop Summary",
})
}
}
return
}
// Update progress
ctx.SetWidget(ext.WidgetConfig{
ID: "loop-progress",
Placement: ext.WidgetAbove,
Content: ext.WidgetContent{Text: fmt.Sprintf("🔄 Loop: %d/%d - %s", loopCurrent, loopCount, loopPrompt)},
Style: ext.WidgetStyle{BorderColor: "#fab387"},
})
// Collapse previous iteration for fresh context
branch := ctx.GetCurrentBranch()
if len(branch) >= 2 {
// Find the user messages (look for the one before the last assistant message)
// We want to collapse from the user message that started this iteration
// to the last assistant response
var collapseStartIdx = -1
for i := len(branch) - 1; i >= 0; i-- {
if branch[i].Role == "assistant" {
// Found the last assistant message, now find the user message before it
for j := i - 1; j >= 0; j-- {
if branch[j].Role == "user" {
collapseStartIdx = j
break
}
}
break
}
}
if collapseStartIdx >= 0 {
fromID := branch[collapseStartIdx].ID
toID := branch[len(branch)-1].ID
ctx.PrintInfo(fmt.Sprintf("Collapsing iteration %d for fresh context...", loopCurrent))
summary := ctx.SummarizeBranch(fromID, toID)
if summary != "" {
result := ctx.CollapseBranch(fromID, toID, summary)
if result.Success {
ctx.PrintInfo("Context collapsed successfully")
} else {
ctx.PrintError(fmt.Sprintf("Collapse failed: %s", result.Error))
}
}
}
}
// Small delay to let UI update
time.Sleep(500 * time.Millisecond)
// Trigger next iteration
executeLoopIteration(ctx)
})
}
// showTree displays the conversation tree structure
func showTree(ctx ext.Context) {
branch := ctx.GetCurrentBranch()
if len(branch) == 0 {
ctx.PrintInfo("Tree is empty")
return
}
var output strings.Builder
output.WriteString(fmt.Sprintf("Conversation Tree (%d nodes):\n\n", len(branch)))
for i, node := range branch {
prefix := " "
if i == len(branch)-1 {
prefix = "▶ " // Current node
} else {
prefix = " "
}
roleIcon := "💬"
switch node.Role {
case "user":
roleIcon = "👤"
case "assistant":
roleIcon = "🤖"
case "system":
roleIcon = "⚙️"
}
content := truncate(node.Content, 50)
if node.Type == "branch_summary" {
roleIcon = "📋"
content = "[Summary] " + truncate(node.Content, 40)
}
output.WriteString(fmt.Sprintf("%s%s %s: %s (%s...)\n", prefix, roleIcon, node.Role, node.ID[:8], content))
// Show children count if any
children := ctx.GetChildren(node.ID)
if len(children) > 0 {
output.WriteString(fmt.Sprintf(" └─ %d branch(es)\n", len(children)))
}
}
ctx.PrintBlock(ext.PrintBlockOpts{
Text: output.String(),
BorderColor: "#89b4fa",
Subtitle: "conversation-manager · Tree View",
})
}
// showBranch displays the current branch path
func showBranch(ctx ext.Context) {
branch := ctx.GetCurrentBranch()
if len(branch) == 0 {
ctx.PrintInfo("No active branch")
return
}
var output strings.Builder
output.WriteString(fmt.Sprintf("Current Branch (%d nodes from root to leaf):\n\n", len(branch)))
for i, node := range branch {
marker := " "
if i == len(branch)-1 {
marker = "▶ " // Current leaf
}
output.WriteString(fmt.Sprintf("%s[%d] %s (%s): %s\n",
marker, i, node.Type, node.ID[:8], truncate(node.Content, 40)))
}
// Show current node details
leaf := branch[len(branch)-1]
output.WriteString(fmt.Sprintf("\nCurrent Leaf:\n"))
output.WriteString(fmt.Sprintf(" ID: %s\n", leaf.ID))
output.WriteString(fmt.Sprintf(" Type: %s\n", leaf.Type))
output.WriteString(fmt.Sprintf(" Role: %s\n", leaf.Role))
output.WriteString(fmt.Sprintf(" Model: %s\n", leaf.Model))
output.WriteString(fmt.Sprintf(" Children: %d\n", len(leaf.Children)))
ctx.PrintBlock(ext.PrintBlockOpts{
Text: output.String(),
BorderColor: "#cba6f7",
Subtitle: "conversation-manager · Branch View",
})
}
// executeLoopIteration triggers the next loop iteration
func executeLoopIteration(ctx ext.Context) {
iterationPrompt := fmt.Sprintf("[%d/%d] %s", loopCurrent+1, loopCount, loopPrompt)
ctx.SendMessage(iterationPrompt)
}
// truncate helper
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max-3] + "..."
}
+4 -6
View File
@@ -7,12 +7,10 @@
// development: edit your extension source, then type /reload to pick up
// changes immediately.
//
// Note: Extensions in autoloaded directories (~/.config/kit/extensions/
// and .kit/extensions/) are automatically reloaded on save. The /reload
// command is useful for extensions loaded via -e from other locations.
//
// Event handlers, slash commands, tool definitions, tool renderers,
// message renderers, and keyboard shortcuts all update immediately.
// Event handlers, slash commands, tool renderers, message renderers, and
// keyboard shortcuts update immediately. Extension-defined tools are NOT
// updated (they are baked into the agent at creation time and require a
// restart).
//
// Commands:
// /reload — hot-reload all extensions from disk
+19 -57
View File
@@ -10,21 +10,13 @@ import (
"kit/ext"
)
// re matches !{...} with non-greedy content.
var re = regexp.MustCompile(`!\{([^}]+)\}`)
// Init expands inline bash expressions in user prompts before they reach the
// LLM. Text like !{git rev-parse --abbrev-ref HEAD} is replaced with the
// command's stdout.
//
// In interactive mode the expansion happens at submit time via an editor
// interceptor, so the expanded text is also visible in the user message
// block on screen. In non-interactive mode (CLI, script, queue) the
// expansion happens via OnInput transform.
// LLM. Text like !{git branch --show-current} is replaced with the command's
// stdout.
//
// Examples:
//
// "Fix the tests on !{git rev-parse --abbrev-ref HEAD}"
// "Fix the tests on !{git branch --show-current}"
// → "Fix the tests on main"
//
// "The current directory is !{pwd}"
@@ -32,59 +24,29 @@ var re = regexp.MustCompile(`!\{([^}]+)\}`)
//
// Usage: kit -e examples/extensions/inline-bash.go
func Init(api ext.API) {
// ── Interactive mode: editor interceptor ──────────────────────────
// Intercept Enter / Ctrl+D so we can expand !{...} BEFORE the
// SubmitMsg is created. This ensures the expanded text appears in
// the user message block on screen as well as in the LLM prompt.
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
if !ctx.Interactive {
return
}
ctx.SetEditor(ext.EditorConfig{
HandleKey: func(key string, currentText string) ext.EditorKeyAction {
if (key == "enter" || key == "ctrl+d") && re.MatchString(currentText) {
expanded := expand(currentText)
// Clear the textarea asynchronously — calling
// SetEditorText synchronously from inside Update()
// would deadlock the BubbleTea event loop.
go ctx.SetEditorText("")
return ext.EditorKeyAction{
Type: ext.EditorKeySubmit,
SubmitText: expanded,
}
}
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
},
})
})
// Matches !{...} with non-greedy content.
re := regexp.MustCompile(`!\{([^}]+)\}`)
// ── Non-interactive fallback: OnInput transform ──────────────────
// For CLI, script, and queue sources the editor interceptor is not
// active, so we fall back to OnInput which still rewrites the
// prompt text sent to the LLM.
api.OnInput(func(ev ext.InputEvent, ctx ext.Context) *ext.InputResult {
if ev.Source == "interactive" || !re.MatchString(ev.Text) {
if !re.MatchString(ev.Text) {
return nil
}
expanded := re.ReplaceAllStringFunc(ev.Text, func(match string) string {
// Extract the command between !{ and }.
cmd := re.FindStringSubmatch(match)[1]
cmd = strings.TrimSpace(cmd)
out, err := exec.Command("bash", "-c", cmd).Output()
if err != nil {
return match // keep original on error
}
return strings.TrimSpace(string(out))
})
return &ext.InputResult{
Action: "transform",
Text: expand(ev.Text),
Text: expanded,
}
})
}
// expand replaces every !{cmd} in text with the command's stdout.
// On error the original !{cmd} token is preserved.
func expand(text string) string {
return re.ReplaceAllStringFunc(text, func(match string) string {
cmd := re.FindStringSubmatch(match)[1]
cmd = strings.TrimSpace(cmd)
out, err := exec.Command("bash", "-c", cmd).Output()
if err != nil {
return match // keep original on error
}
return strings.TrimSpace(string(out))
})
}
+2 -75
View File
@@ -168,10 +168,6 @@ var (
// Test
pendingTest *PendingTest
// Typing indicator
typingTicker *time.Ticker
typingStop chan struct{}
// Latest context for background goroutines
latestCtx ext.Context
latestCtxSet bool
@@ -207,23 +203,8 @@ func configDir() string {
return filepath.Join(home, ".config", "kit")
}
func globalConfigDir() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "kit")
}
func configPath() string {
// Prefer project-local config, fall back to global config.
local := filepath.Join(configDir(), "kit-telegram.json")
if _, err := os.Stat(local); err == nil {
return local
}
global := filepath.Join(globalConfigDir(), "kit-telegram.json")
if _, err := os.Stat(global); err == nil {
return global
}
// Neither exists — return local path (will be created on connect).
return local
return filepath.Join(configDir(), "kit-telegram.json")
}
func failureLogDir() string {
@@ -406,14 +387,6 @@ func tgEditMessageText(token string, chatID int64, messageID int, text string) (
return &msg, nil
}
func tgSendChatAction(token string, chatID int64, action string) error {
_, err := telegramRequest(token, "sendChatAction", map[string]any{
"chat_id": chatID,
"action": action,
}, 15)
return err
}
// ──────────────────────────────────────────────
// Error classification
// ──────────────────────────────────────────────
@@ -664,48 +637,6 @@ func clearHealthTimer() {
}
}
// ──────────────────────────────────────────────
// Typing indicator
// ──────────────────────────────────────────────
func startTypingLoop() {
mu.Lock()
defer mu.Unlock()
if typingTicker != nil {
return
}
cfg := config
if cfg == nil || !cfg.Enabled {
return
}
token := cfg.BotToken
chatID := cfg.ChatID
typingTicker = time.NewTicker(4 * time.Second)
typingStop = make(chan struct{})
// Send immediately, then every 4 seconds.
go func() {
tgSendChatAction(token, chatID, "typing")
for {
select {
case <-typingTicker.C:
tgSendChatAction(token, chatID, "typing")
case <-typingStop:
return
}
}
}()
}
func stopTypingLoop() {
mu.Lock()
defer mu.Unlock()
if typingTicker != nil {
typingTicker.Stop()
close(typingStop)
typingTicker = nil
}
}
// ──────────────────────────────────────────────
// Polling lifecycle
// ──────────────────────────────────────────────
@@ -977,7 +908,7 @@ func summarizeToolAction(toolName string, inputJSON string) string {
return "searching " + getStr("pattern", "text")
case "ls":
return "listing " + getStr("path", "directory")
case "subagent":
case "spawn_subagent":
return "spawning subagent"
default:
return "using " + toolName
@@ -2174,7 +2105,6 @@ func Init(api ext.API) {
mu.Unlock()
sendShutdownDisconnectedMessage()
stopTypingLoop()
stopPolling()
clearHealthTimer()
clearFooter()
@@ -2198,7 +2128,6 @@ func Init(api ext.API) {
mu.Unlock()
report("run.start", fmt.Sprintf("runId=%d", run.ID))
startTypingLoop()
ensureProgressMessage()
updateProgressMessage()
})
@@ -2211,8 +2140,6 @@ func Init(api ext.API) {
run := activeRun
mu.Unlock()
stopTypingLoop()
if run != nil {
// Capture final response from event
if e.Response != "" {
+4 -2
View File
@@ -2,7 +2,9 @@
// lsp-diagnostics.go — LSP-powered diagnostics for Kit's edit tool.
//
// Starts language servers on demand and surfaces diagnostics after file edits:
// Starts language servers on demand and surfaces diagnostics after file edits,
// following the same pattern used by Charm's crush editor:
//
// 1. After an edit, notify the LSP server of the file change
// 2. Wait for the server to publish fresh diagnostics
// 3. Append diagnostic output to the edit tool's result
@@ -410,7 +412,7 @@ func (c *lspClient) changeFile(absPath, content string) {
}
// waitForDiagnostics polls until the server publishes new diagnostics or
// the timeout elapses.
// the timeout elapses. Mirrors crush's WaitForDiagnostics pattern.
func (c *lspClient) waitForDiagnostics(timeout time.Duration) {
c.diagMu.Lock()
startVersion := c.diagVersion
-269
View File
@@ -1,269 +0,0 @@
//go:build ignore
// prompt-templates.go - Frontmatter-driven prompt templates with model switching.
// This extension demonstrates the new bridged SDK APIs:
// - Tree navigation for conversation management
// - Template parsing with {{variable}} substitution
// - Model resolution with fallback chains
// - Skill injection
//
// Usage:
// 1. Create ~/.config/kit/prompts/debug.md with frontmatter:
// ---
// description: Debug Python code
// model: claude-sonnet-4-20250514
// skill: python
// ---
// Help me debug this Python code: {{input}}
//
// 2. In Kit: /debug my_script.py
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"kit/ext"
)
// PromptTemplate represents a loaded template with frontmatter
type PromptTemplate struct {
Name string
Description string
Model string
Skill string
Content string
Variables []string
Path string
}
var (
templates = make(map[string]PromptTemplate)
templateDir string
)
func Init(api ext.API) {
// Determine template directory
home, _ := os.UserHomeDir()
templateDir = filepath.Join(home, ".config", "kit", "prompts")
// Ensure directory exists
os.MkdirAll(templateDir, 0755)
// Register commands
api.RegisterCommand(ext.CommandDef{
Name: "reload-templates",
Description: "Reload prompt templates from disk",
Execute: func(args string, ctx ext.Context) (string, error) {
loadTemplates(ctx)
ctx.PrintInfo(fmt.Sprintf("Loaded %d templates from %s", len(templates), templateDir))
return "", nil
},
})
// Dynamic template commands are registered after loading
api.OnSessionStart(func(e ext.SessionStartEvent, ctx ext.Context) {
loadTemplates(ctx)
registerTemplateCommands(api, ctx)
})
}
// loadTemplates discovers and loads all template files
func loadTemplates(ctx ext.Context) {
templates = make(map[string]PromptTemplate)
entries, err := os.ReadDir(templateDir)
if err != nil {
return
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
continue
}
path := filepath.Join(templateDir, entry.Name())
tpl, err := loadTemplateFile(path)
if err != nil {
continue
}
name := strings.TrimSuffix(entry.Name(), ".md")
templates[name] = tpl
}
}
// loadTemplateFile parses a template with YAML frontmatter
func loadTemplateFile(path string) (PromptTemplate, error) {
data, err := os.ReadFile(path)
if err != nil {
return PromptTemplate{}, err
}
content := string(data)
tpl := PromptTemplate{Path: path}
// Parse frontmatter
if strings.HasPrefix(content, "---") {
parts := strings.SplitN(content[3:], "---", 2)
if len(parts) == 2 {
frontmatter := strings.TrimSpace(parts[0])
body := strings.TrimSpace(parts[1])
// Simple line-by-line frontmatter parsing
for _, line := range strings.Split(frontmatter, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, found := strings.Cut(line, ":")
if found {
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
switch key {
case "description":
tpl.Description = value
case "model":
tpl.Model = value
case "skill":
tpl.Skill = value
}
}
}
tpl.Content = body
} else {
tpl.Content = content
}
} else {
tpl.Content = content
}
// Parse {{variables}} using simple string parsing
// (Can't use ctx.ParseTemplate here since we're in Init, not a handler)
var vars []string
for {
start := strings.Index(tpl.Content, "{{")
if start == -1 {
break
}
end := strings.Index(tpl.Content[start:], "}}")
if end == -1 {
break
}
varName := strings.TrimSpace(tpl.Content[start+2 : start+end])
vars = append(vars, varName)
tpl.Content = tpl.Content[:start] + "{{" + varName + "}}" + tpl.Content[start+end+2:]
}
tpl.Variables = vars
return tpl, nil
}
// registerTemplateCommands dynamically registers commands for each template
func registerTemplateCommands(api ext.API, ctx ext.Context) {
for name, tpl := range templates {
// Skip if already registered (we'd need to track this)
tplCopy := tpl // Capture for closure
nameCopy := name
// Build description with metadata
desc := tplCopy.Description
if desc == "" {
desc = fmt.Sprintf("Run %s template", nameCopy)
}
if tplCopy.Model != "" {
desc += fmt.Sprintf(" [%s", tplCopy.Model)
if tplCopy.Skill != "" {
desc += fmt.Sprintf(" +%s", tplCopy.Skill)
}
desc += "]"
}
api.RegisterCommand(ext.CommandDef{
Name: nameCopy,
Description: desc,
Execute: func(args string, ctx ext.Context) (string, error) {
return executeTemplate(ctx, tplCopy, args)
},
})
}
}
// executeTemplate runs a template with the given arguments
func executeTemplate(ctx ext.Context, tpl PromptTemplate, args string) (string, error) {
// Store original model for restoration
originalModel := ctx.Model
// 1. Resolve and switch model if specified
if tpl.Model != "" {
// Parse model chain (comma-separated)
preferences := strings.Split(tpl.Model, ",")
for i := range preferences {
preferences[i] = strings.TrimSpace(preferences[i])
}
result := ctx.ResolveModelChain(preferences)
if result.Error != "" {
ctx.PrintError(fmt.Sprintf("Model resolution failed: %s", result.Error))
// Continue with current model
} else {
ctx.PrintInfo(fmt.Sprintf("Switching to model: %s", result.Model))
if err := ctx.SetModel(result.Model); err != nil {
ctx.PrintError(fmt.Sprintf("Failed to switch model: %s", err.Error()))
}
}
}
// 2. Inject skill if specified
if tpl.Skill != "" {
err := ctx.InjectSkillAsContext(tpl.Skill)
if err != "" {
ctx.PrintError(fmt.Sprintf("Skill injection failed: %s", err))
} else {
ctx.PrintInfo(fmt.Sprintf("Injected skill: %s", tpl.Skill))
}
}
// 3. Parse and render template
parsed := ctx.ParseTemplate(tpl.Name, tpl.Content)
// Build variable map
vars := make(map[string]string)
// Simple argument parsing: first arg is $1 (input), rest is $@
if len(parsed.Variables) > 0 {
argsList := ctx.SimpleParseArguments(args, len(parsed.Variables))
for i, varName := range parsed.Variables {
if i < len(parsed.Variables) && i+1 < len(argsList) {
vars[varName] = argsList[i+1]
}
}
// If single variable, use full args
if len(parsed.Variables) == 1 && vars[parsed.Variables[0]] == "" {
vars[parsed.Variables[0]] = args
}
}
// Render with model conditionals
content := ctx.RenderWithModelConditionals(tpl.Content)
rendered := ctx.RenderTemplate(ext.PromptTemplate{Name: tpl.Name, Content: content, Variables: parsed.Variables}, vars)
// 4. Send the rendered prompt
ctx.SendMessage(rendered)
// 5. Schedule model restoration after turn completes
// We use a goroutine to wait and restore
if tpl.Model != "" && originalModel != "" {
go func() {
// Note: In a real implementation, we'd use OnAgentEnd event
// For now, the user can manually switch back
ctx.SetStatus("template-mode", fmt.Sprintf("Template: %s (model will restore)", tpl.Name), 20)
}()
}
return fmt.Sprintf("Executing template: %s", tpl.Name), nil
}
@@ -130,58 +130,6 @@ 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("../../.kit/extensions/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) {
+1 -1
View File
@@ -37,7 +37,7 @@ func Init(api ext.API) {
"Subagent Test Extension loaded\n\n" +
"/subtest <task> Spawn blocking subagent\n" +
"/subbg <task> Spawn background subagent\n\n" +
"The LLM can also use the subagent tool.")
"The LLM can also use the spawn_subagent tool.")
})
api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) {
-153
View File
@@ -1,153 +0,0 @@
//go:build ignore
// sudo-handler.go - Extension to handle sudo password prompts securely
//
// This extension intercepts bash commands containing "sudo" and:
// 1. Checks if sudo credentials are already cached (via sudo -n)
// 2. If not cached, prompts the user for their password (with masking)
// 3. Temporarily sets SUDO_PASSWORD environment variable for execution
// 4. The bash tool automatically uses sudo -S -p '' to pipe the password
//
// Usage: kit -e examples/extensions/sudo-handler.go
//
// Security notes:
// - Password is only stored in memory for the duration of the session
// - Password is never logged or displayed
// - Each session requires re-authentication (sudo -k is used)
// - The SUDO_PASSWORD env var is set only during tool execution
package main
import (
"encoding/json"
"os"
"strings"
"sync"
"kit/ext"
)
var (
// cachedPassword stores the sudo password for the session
cachedPassword string
// hasCachedPassword tracks if we have a valid cached password
hasCachedPassword bool
// mu protects cached password access
mu sync.RWMutex
)
// Init sets up the sudo handler extension
func Init(api ext.API) {
api.OnToolCall(func(tc ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
if tc.ToolName != "bash" {
return nil
}
// Parse the command from tool input
var input struct {
Command string `json:"command"`
}
if err := json.Unmarshal([]byte(tc.Input), &input); err != nil {
return nil
}
// Check if command contains sudo
if !containsSudo(input.Command) {
return nil
}
// Check if we already have cached credentials
mu.RLock()
password := cachedPassword
hasCached := hasCachedPassword
mu.RUnlock()
if hasCached {
// Use cached password
os.Setenv("SUDO_PASSWORD", password)
return nil
}
// No cached password - prompt user
result := ctx.PromptInput(ext.PromptInputConfig{
Message: "🔐 Sudo password required for:\n " + truncateCommand(input.Command, 60),
Placeholder: "Enter your password",
})
if result.Cancelled {
return &ext.ToolCallResult{
Block: true,
Reason: "Sudo password prompt cancelled by user",
}
}
if result.Value == "" {
return &ext.ToolCallResult{
Block: true,
Reason: "No password provided",
}
}
// Cache the password for this session
mu.Lock()
cachedPassword = result.Value
hasCachedPassword = true
mu.Unlock()
// Set environment variable for the bash tool to use
os.Setenv("SUDO_PASSWORD", result.Value)
// Show confirmation (without revealing password)
ctx.PrintInfo("Sudo password cached for this session")
return nil
})
// Clear cached password when session ends
api.OnSessionShutdown(func(event ext.SessionShutdownEvent, ctx ext.Context) {
mu.Lock()
cachedPassword = ""
hasCachedPassword = false
mu.Unlock()
os.Unsetenv("SUDO_PASSWORD")
})
}
// containsSudo checks if the command contains sudo as a command (not in a string)
func containsSudo(command string) bool {
// Simple check for sudo as a word, not inside quotes or as part of another word
lower := strings.ToLower(command)
// Check for sudo at start or after separators
patterns := []string{
"sudo ",
"sudo\t",
";sudo ",
"&& sudo ",
"|| sudo ",
"| sudo ",
"$(sudo ",
"`sudo ",
}
for _, pattern := range patterns {
if strings.Contains(lower, pattern) {
return true
}
}
// Check if command starts with sudo
if strings.HasPrefix(lower, "sudo ") {
return true
}
return false
}
// truncateCommand truncates a long command for display
func truncateCommand(cmd string, maxLen int) string {
if len(cmd) <= maxLen {
return cmd
}
return cmd[:maxLen-3] + "..."
}
+1 -1
View File
@@ -62,7 +62,7 @@ func main() {
}
})
// Subscribe to streaming chunks.
host3.OnMessageUpdate(func(e kit.MessageUpdateEvent) {
host3.OnStreaming(func(e kit.MessageUpdateEvent) {
fmt.Print(e.Chunk)
})
+71 -68
View File
@@ -1,99 +1,100 @@
module github.com/mark3labs/kit
go 1.26.2
go 1.26.1
require (
charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.6
charm.land/fantasy v0.23.0
charm.land/bubbles/v2 v2.0.0
charm.land/bubbletea/v2 v2.0.2
charm.land/fantasy v0.17.1
charm.land/huh/v2 v2.0.3
charm.land/lipgloss/v2 v2.0.3
github.com/alecthomas/chroma/v2 v2.24.1
github.com/atotto/clipboard v0.1.4
charm.land/lipgloss/v2 v2.0.2
github.com/alecthomas/chroma/v2 v2.23.1
github.com/aymanbagabas/go-udiff v0.4.1
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-20260428153724-66037269d7be
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.12.2
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.51.0
github.com/coder/acp-go-sdk v0.6.3
github.com/mark3labs/mcp-go v0.46.0
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/term v0.41.0
gopkg.in/yaml.v3 v3.0.1
)
require (
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.20.0 // indirect
cloud.google.com/go/auth v0.19.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.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.7 // 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.17 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // 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.23 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect
github.com/aws/smithy-go v1.25.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.4 // 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.12 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // 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.20 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
github.com/aymerick/douceur v0.2.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/openai-go v0.0.0-20260319145158-d0740cc34266 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260316091819-b93f6a3b8502 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260503005035-c113ba3d2310 // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260323091123-df7b1bcffcca // indirect
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20260503005035-c113ba3d2310 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20260323091123-df7b1bcffcca // 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.12.0 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // 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-20260430182902-b6187a392ed4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // 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/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/google/jsonschema-go v0.4.2 // 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.15 // indirect
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
github.com/googleapis/gax-go/v2 v2.20.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/kaptinlin/go-i18n v0.4.7 // indirect
github.com/kaptinlin/jsonpointer v0.4.21 // indirect
github.com/kaptinlin/jsonschema v0.7.13 // indirect
github.com/kaptinlin/messageformat-go v0.6.3 // indirect
github.com/kaptinlin/go-i18n v0.2.12 // indirect
github.com/kaptinlin/jsonpointer v0.4.17 // indirect
github.com/kaptinlin/jsonschema v0.7.6 // indirect
github.com/kaptinlin/messageformat-go v0.4.18 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // 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/reflow v0.3.0 // indirect
github.com/muesli/roff v0.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
github.com/pelletier/go-toml/v2 v2.3.0 // 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
@@ -104,39 +105,41 @@ require (
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/yuin/goldmark v1.8.2 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // 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.67.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
go.opentelemetry.io/otel v1.42.0 // indirect
go.opentelemetry.io/otel/metric v1.42.0 // indirect
go.opentelemetry.io/otel/trace v1.42.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.49.0 // indirect
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/api v0.277.0 // indirect
google.golang.org/genai v1.55.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 // indirect
google.golang.org/grpc v1.81.0 // indirect
google.golang.org/api v0.273.0 // indirect
google.golang.org/genai v1.51.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/x/ansi v0.11.7
github.com/charmbracelet/glamour v1.0.0
github.com/charmbracelet/x/ansi v0.11.6
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.22 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.21 // 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
github.com/spf13/pflag v1.0.10 // indirect
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.42.0 // indirect
golang.org/x/text v0.35.0
)
+146 -138
View File
@@ -1,73 +1,75 @@
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.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo=
charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g=
charm.land/fantasy v0.23.0 h1:pocjwC5CxfEg1Bpwb0raML2d5ijo3op33Mmd6hYJyo4=
charm.land/fantasy v0.23.0/go.mod h1:4yzSsd9XmFEVjRnF1P0LTEbLTmQX6OLnPkrHaf7iruo=
charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
charm.land/fantasy v0.17.1 h1:SQzfnyJPDuQWt6e//KKmQmEEXdqHMC0IZz10XwkLcEM=
charm.land/fantasy v0.17.1/go.mod h1:FF5ALCCHETacHJPBqU42CtwMInYQ0ul52fdzIHQMbQk=
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/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs=
charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
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=
cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
cloud.google.com/go/auth v0.19.0 h1:DGYwtbcsGsT1ywuxsIoWi1u/vlks0moIblQHgSDgQkQ=
cloud.google.com/go/auth v0.19.0/go.mod h1:2Aph7BT2KnaSFOM0JDPyiYgNh6PL9vGMiP8CUIXZ+IY=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
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.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/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=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
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.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM=
github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI=
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/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
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.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
github.com/aws/aws-sdk-go-v2 v1.41.7/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.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU=
github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE=
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU=
github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
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/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio=
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k=
github.com/aws/aws-sdk-go-v2 v1.41.4/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.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0=
github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g=
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc=
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.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 h1:kiIDLZ005EcKomYYITtfsjn7dtOwHDOFy7IbPXKek2o=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13/go.mod h1:2h/xGEowcW/g38g06g3KpRWDlT+OTfxxI0o1KqayAB8=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB8KfgAEuG0dc08Bkda7NU=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17/go.mod h1:Al9fFsXjv4KfbzQHGe6V4NZSZQXecFcvaIF4e70FoRA=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8MSU6Ch5i9PgBkcU=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/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/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@@ -78,6 +80,8 @@ github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/fang v1.0.0 h1:jESBY40agJOlLYnnv9jE0mLqDGTxEk0hkOnx7YGyRlQ=
github.com/charmbracelet/fang v1.0.0/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo=
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
@@ -86,26 +90,24 @@ 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-20260428153724-66037269d7be h1:j7w8VP/D4lu5+/4GamMmFy8nrtadcl82/fjvDgSHwLo=
github.com/charmbracelet/ultraviolet v0.0.0-20260428153724-66037269d7be/go.mod h1:3YdTxlnV/L0bQ3VN8WOSw8doF7LZV/xawUQ4MuAPDvo=
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/ultraviolet v0.0.0-20260316091819-b93f6a3b8502 h1:hzWNs3UQRSUTS6YCbLaQnwqKBFXT5Yh1OOw6+26apqg=
github.com/charmbracelet/ultraviolet v0.0.0-20260316091819-b93f6a3b8502/go.mod h1:mkUCcxn9w9j89JJp3pOza5tmDQZPgIB75UfmQlFYvas=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
github.com/charmbracelet/x/editor v0.2.0 h1:7XLUKtaRaB8jN7bWU2p2UChiySyaAuIfYiIRg8gGWwk=
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-20260503005035-c113ba3d2310 h1:rByFKh9JgQScu7oy0+TlUbC2e93woW/QNZmNXbbbw/E=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260503005035-c113ba3d2310/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260323091123-df7b1bcffcca h1:62yAoS1Ynbuzwcn1LkNBxi3IMF5p0E0cHCoaLOOmN9w=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260323091123-df7b1bcffcca/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-20260503005035-c113ba3d2310 h1:PMjHdSo8Vpq9psUw9BoHo9JLPMkm9Hqb+Whk64n3AQQ=
github.com/charmbracelet/x/exp/slice v0.0.0-20260503005035-c113ba3d2310/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
github.com/charmbracelet/x/exp/slice v0.0.0-20260323091123-df7b1bcffcca h1:QQoyQLgUzojMNWHVHToN6d9qTvT0KWtxUKIRPx/Ox5o=
github.com/charmbracelet/x/exp/slice v0.0.0-20260323091123-df7b1bcffcca/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=
@@ -124,15 +126,15 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ
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.12.2 h1:fpRJ8Z5HMSr5cZ5IywzFlFZcIxZOsto+laNVu7XelFA=
github.com/coder/acp-go-sdk v0.12.2/go.mod h1:yKzM/3R9uELp4+nBAwwtkS0aN1FOFjo11CNPy37yFko=
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/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.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -146,10 +148,10 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
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.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4 h1:2WmHkJINIjgXXYDGik8d3oJvFA3DAwPy00csDJ3vo+o=
github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
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/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=
@@ -167,48 +169,49 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
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.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
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/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.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas=
github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
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/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.20.0 h1:NIKVuLhDlIV74muWlsMM4CcQZqN6JJ20Qcxd9YMuYcs=
github.com/googleapis/gax-go/v2 v2.20.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
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/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
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.7 h1:apjIIZHnGRyrkiX3vHj07F1BF6D0JLmV+VGSr1781Jc=
github.com/kaptinlin/go-i18n v0.4.7/go.mod h1:+i1J0pFq/9i9ESC5qRMVkKwC+mdQTABhhBExpYOlbeM=
github.com/kaptinlin/jsonpointer v0.4.21 h1:WVkwQbeerbHFcoXG7Yo/mlQhhZjWiTnagECEfwDXXa0=
github.com/kaptinlin/jsonpointer v0.4.21/go.mod h1:Mo7+DX8RlQTFqS4dnYJl0izSP4ob+Rl5xO/mGDETgaU=
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.3 h1:m9ZE/fCjnsk8bdkv7Qs56L/ZoHbmQqhz9mRZSAQLU5g=
github.com/kaptinlin/messageformat-go v0.6.3/go.mod h1:2KOZ/hgo/SveZ+uyi7vPUpUXieX65Mppzbc3VpGyqKs=
github.com/kaptinlin/go-i18n v0.2.12 h1:ywDsvb4KDFddMC2dpI/rrIzGU2mWUSvHmWUm9BMsdl4=
github.com/kaptinlin/go-i18n v0.2.12/go.mod h1:pVcu9qsW5pOIOoZFJXesRYmLos1vMQrby70JPAoWmJU=
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.6 h1:UUMqZGFAk7nOzQsYAxvgygm4wpDp/nwXxA4VP9mCPCs=
github.com/kaptinlin/jsonschema v0.7.6/go.mod h1:GGk/oE+F1lWUfYrzKaCf4QWZmMdytt0LL4XdFEFB0LE=
github.com/kaptinlin/messageformat-go v0.4.18 h1:RBlHVWgZyoxTcUgGWBsl2AcyScq/urqbLZvzgryTmSI=
github.com/kaptinlin/messageformat-go v0.4.18/go.mod h1:ntI3154RnqJgr7GaC+vZBnIExl2V3sv9selvRNNEM24=
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=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
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.51.0 h1:e8AhEfxzcYt7XqYzwT7uzWNhnqpu3H1Tn7dEJB9Ygj8=
github.com/mark3labs/mcp-go v0.51.0/go.mod h1:Zg9cB2HdwdMMVgY0xtTzq3KvYIOJQDsaut+jWjwDaQY=
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.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mark3labs/mcp-go v0.46.0 h1:8KRibF4wcKejbLsHxCA/QBVUr5fQ9nwz/n8lGqmaALo=
github.com/mark3labs/mcp-go v0.46.0/go.mod h1:JKTC7R2LLVagkEWK7Kwu7DbmA6iIvnNAod6yrHiQMag=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
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=
@@ -219,18 +222,22 @@ github.com/muesli/mango-cobra v1.3.0 h1:vQy5GvPg3ndOSpduxutqFoINhWk3vD5K2dXo5E8p
github.com/muesli/mango-cobra v1.3.0/go.mod h1:Cj1ZrBu3806Qw7UjxnAUgE+7tllUBj1NCLQDwwGx19E=
github.com/muesli/mango-pflag v0.2.0 h1:QViokgKDZQCzKhYe1zH8D+UlPJzBSGoP9yx0hBG0t5k=
github.com/muesli/mango-pflag v0.2.0/go.mod h1:X9LT1p/pbGA1wjvEbtwnixujKErkP0jVmrxwrw3fL0Y=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
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.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
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/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/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
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,8 +245,6 @@ 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/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/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=
@@ -274,56 +279,59 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI
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/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
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/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
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=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
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.277.0 h1:HJfyJUiNeBBUMai7ez8u14wkp/gH/I4wpGbbO9o+cSk=
google.golang.org/api v0.277.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ=
google.golang.org/genai v1.55.0 h1:iLHGk4Bj/IZ/GNNZb7hYqwSJMRBvqLeu2Hb6YQ+rYGw=
google.golang.org/genai v1.55.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
google.golang.org/genproto v0.0.0-20260427160629-7cedc36a6bc4 h1:2iMJZntwvmfgtse+s744JY7v7PgEdSBuFYXucvpOHNM=
google.golang.org/genproto v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:v14kaaboYyXQ1Gsu489Q+Hg/oN4B33mWtuOhF1HCeXA=
google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4 h1:yOzSCGPx+cp5VO7IxvZ9SBFF7j1tZVcNtlHR2iYKtVo=
google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:Q9HWtNeE7tM9npdIsEvqXj1QJIvVoeAV3rtXtS715Cw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 h1:tEkOQcXgF6dH1G+MVKZrfpYvozGrzb91k6ha7jireSM=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw=
google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.273.0 h1:r/Bcv36Xa/te1ugaN1kdJ5LoA5Wj/cL+a4gj6FiPBjQ=
google.golang.org/api v0.273.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew=
google.golang.org/genai v1.51.0 h1:IZGuUqgfx40INv3hLFGCbOSGp0qFqm7LVmDghzNIYqg=
google.golang.org/genai v1.51.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI=
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
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=
+16 -279
View File
@@ -7,11 +7,8 @@ package acpserver
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"strings"
"sync/atomic"
"github.com/charmbracelet/log"
@@ -23,6 +20,7 @@ import (
// Version is injected at build time; fallback to "dev".
var Version = "dev"
// Agent implements the acp.Agent interface, delegating to Kit for LLM
// execution, tool calls, and session management.
type Agent struct {
conn *acp.AgentSideConnection
@@ -113,20 +111,13 @@ func (a *Agent) Prompt(ctx context.Context, params acp.PromptRequest) (acp.Promp
)
}
// Extract text and file attachments from prompt content blocks.
promptText, files := extractPromptContent(params.Prompt)
if promptText == "" && len(files) == 0 {
// Extract text from prompt content blocks.
promptText := extractPromptText(params.Prompt)
if promptText == "" {
return acp.PromptResponse{}, acp.NewInvalidParams("empty prompt")
}
// If we have files but no text prompt, add a default prompt
// This is required because the underlying LLM library needs a non-empty prompt
// when there are no previous messages in the conversation.
if promptText == "" && len(files) > 0 {
promptText = "Please analyze the attached file."
}
log.Debug("acp: prompt", "session", sessionID, "prompt_len", len(promptText), "files", len(files))
log.Debug("acp: prompt", "session", sessionID, "prompt_len", len(promptText))
// Create a cancellable context for this prompt turn.
promptCtx, cancel := context.WithCancel(ctx)
@@ -138,13 +129,7 @@ func (a *Agent) Prompt(ctx context.Context, params acp.PromptRequest) (acp.Promp
defer unsub()
// Run the prompt through Kit's full turn lifecycle.
// Use PromptResultWithFiles when file attachments are present.
var err error
if len(files) > 0 {
_, err = sess.kit.PromptResultWithFiles(promptCtx, promptText, files)
} else {
_, err = sess.kit.PromptResult(promptCtx, promptText)
}
_, err := sess.kit.PromptResult(promptCtx, promptText)
if err != nil {
if promptCtx.Err() != nil {
return acp.PromptResponse{
@@ -177,77 +162,6 @@ func (a *Agent) SetSessionMode(_ context.Context, _ acp.SetSessionModeRequest) (
return acp.SetSessionModeResponse{}, nil
}
// 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.CloseSessionResponse{}, nil
}
log.Debug("acp: close session", "session", sessionID)
sess.cancelPrompt()
a.registry.remove(sessionID)
return acp.CloseSessionResponse{}, nil
}
// 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")
}
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
}
// ---------------------------------------------------------------------------
// Event streaming: Kit events → ACP SessionUpdate notifications
// ---------------------------------------------------------------------------
@@ -317,196 +231,19 @@ func (a *Agent) subscribeEvents(ctx context.Context, k *kit.Kit, sessionID acp.S
// Helpers
// ---------------------------------------------------------------------------
// extractPromptContent extracts text and file attachments from ACP content blocks.
// It converts supported content blocks (image, audio, resource) to Kit's LLMFilePart.
func extractPromptContent(blocks []acp.ContentBlock) (string, []kit.LLMFilePart) {
var textParts []string
var files []kit.LLMFilePart
log.Debug("acp: extracting content", "blocks", len(blocks))
for i, block := range blocks {
switch {
// Text content
case block.Text != nil:
log.Debug("acp: content block", "index", i, "type", "text", "len", len(block.Text.Text))
textParts = append(textParts, block.Text.Text)
// Image data (base64)
case block.Image != nil:
mimeType := block.Image.MimeType
if mimeType == "" {
mimeType = "image/png" // Default fallback
// extractPromptText extracts the concatenated text content from ACP content
// blocks. Non-text blocks are ignored for now.
func extractPromptText(blocks []acp.ContentBlock) string {
var text string
for _, block := range blocks {
if block.Text != nil {
if text != "" {
text += "\n"
}
log.Debug("acp: content block", "index", i, "type", "image", "mime", mimeType, "data_len", len(block.Image.Data))
if data, err := base64.StdEncoding.DecodeString(block.Image.Data); err == nil {
files = append(files, kit.LLMFilePart{
Filename: "image.png",
Data: data,
MediaType: mimeType,
})
} else {
log.Debug("acp: failed to decode image", "error", err)
}
// Audio data (base64)
case block.Audio != nil:
mimeType := block.Audio.MimeType
if mimeType == "" {
mimeType = "audio/wav" // Default fallback
}
log.Debug("acp: content block", "index", i, "type", "audio", "mime", mimeType)
if data, err := base64.StdEncoding.DecodeString(block.Audio.Data); err == nil {
files = append(files, kit.LLMFilePart{
Filename: "audio.wav",
Data: data,
MediaType: mimeType,
})
} else {
log.Debug("acp: failed to decode audio", "error", err)
}
// Embedded resource (text or binary file content)
case block.Resource != nil:
log.Debug("acp: content block", "index", i, "type", "resource")
res := block.Resource.Resource
// Text resource - append as text content with file reference
if res.TextResourceContents != nil {
uri := res.TextResourceContents.Uri
content := res.TextResourceContents.Text
mimeType := "text/plain"
if res.TextResourceContents.MimeType != nil {
mimeType = *res.TextResourceContents.MimeType
}
log.Debug("acp: text resource", "uri", uri, "mime", mimeType, "len", len(content))
// Text files are included as formatted text, NOT as FilePart
// FilePart is for binary files (images, audio, PDFs) only
textParts = append(textParts, fmt.Sprintf("[File: %s]\n```\n%s\n```", uri, content))
}
// Binary resource (base64 blob) - these become FilePart
if res.BlobResourceContents != nil {
uri := res.BlobResourceContents.Uri
mimeType := "application/octet-stream"
if res.BlobResourceContents.MimeType != nil {
mimeType = *res.BlobResourceContents.MimeType
}
log.Debug("acp: binary resource", "uri", uri, "mime", mimeType, "blob_len", len(res.BlobResourceContents.Blob))
if data, err := base64.StdEncoding.DecodeString(res.BlobResourceContents.Blob); err == nil {
files = append(files, kit.LLMFilePart{
Filename: extractFilenameFromURI(uri),
Data: data,
MediaType: mimeType,
})
} else {
log.Debug("acp: failed to decode binary resource", "error", err)
}
}
// Resource link (file reference without embedded content)
case block.ResourceLink != nil:
uri := block.ResourceLink.Uri
name := block.ResourceLink.Name
log.Debug("acp: content block", "index", i, "type", "resource_link", "uri", uri, "name", name)
// For resource links, we'll try to read the file from disk
// This requires the file URI to be accessible (file:// scheme)
if content, err := readResourceFromURI(uri); err == nil {
// Detect if it's a text file or binary file
mimeType := "text/plain"
if block.ResourceLink.MimeType != nil {
mimeType = *block.ResourceLink.MimeType
}
log.Debug("acp: resource link loaded", "uri", uri, "mime", mimeType, "size", len(content))
// Only create FilePart for binary files (images, audio, PDFs, etc.)
// Text files are included as formatted text in the message
if isTextMimeType(mimeType) || looksLikeText(content) {
textParts = append(textParts, fmt.Sprintf("[File: %s]\n```\n%s\n```", uri, string(content)))
} else {
// Binary file - create FilePart for models that support it
files = append(files, kit.LLMFilePart{
Filename: extractFilenameFromURI(uri),
Data: content,
MediaType: mimeType,
})
}
} else {
// If we can't read it, include as a text reference
log.Debug("acp: resource link failed to load", "uri", uri, "error", err)
textParts = append(textParts, fmt.Sprintf("[Referenced file: %s]", uri))
}
default:
log.Debug("acp: content block", "index", i, "type", "unknown/unhandled")
text += block.Text.Text
}
}
// Debug log the extracted content
for i, f := range files {
log.Debug("acp: extracted file", "index", i, "filename", f.Filename, "mime", f.MediaType, "size", len(f.Data))
}
return strings.Join(textParts, "\n"), files
}
// isTextMimeType returns true if the MIME type indicates text content.
func isTextMimeType(mimeType string) bool {
return strings.HasPrefix(mimeType, "text/") ||
mimeType == "application/json" ||
mimeType == "application/xml" ||
mimeType == "application/javascript" ||
mimeType == "application/typescript" ||
mimeType == "application/x-sh" ||
mimeType == "application/x-python" ||
mimeType == "application/x-yaml" ||
mimeType == "application/x-toml"
}
// looksLikeText checks if the content appears to be text (not binary).
// It samples the first 512 bytes and checks for null bytes or high
// concentration of non-printable characters.
func looksLikeText(data []byte) bool {
if len(data) == 0 {
return true
}
// Check first 512 bytes (or less if file is smaller)
sampleSize := min(len(data), 512)
sample := data[:sampleSize]
// Count non-printable characters
nonPrintable := 0
for _, b := range sample {
// Null byte indicates binary
if b == 0 {
return false
}
// Count control characters (except common whitespace)
if b < 32 && b != '\n' && b != '\r' && b != '\t' {
nonPrintable++
}
}
// If more than 30% non-printable, consider it binary
return float64(nonPrintable)/float64(sampleSize) < 0.3
}
// extractFilenameFromURI extracts a filename from a file URI or path.
func extractFilenameFromURI(uri string) string {
// Handle file:// URIs
uri = strings.TrimPrefix(uri, "file://")
// Extract basename
if idx := strings.LastIndex(uri, "/"); idx >= 0 {
return uri[idx+1:]
}
return uri
}
// readResourceFromURI attempts to read file content from a file:// URI.
func readResourceFromURI(uri string) ([]byte, error) {
if !strings.HasPrefix(uri, "file://") {
return nil, fmt.Errorf("unsupported URI scheme: %s", uri)
}
path := uri[7:] // Remove file:// prefix
return os.ReadFile(path)
return text
}
// parseToolArgs attempts to parse a JSON tool args string into a map for
+86 -33
View File
@@ -8,7 +8,6 @@ import (
"github.com/charmbracelet/log"
"github.com/mark3labs/kit/internal/extbridge"
"github.com/mark3labs/kit/internal/extensions"
kit "github.com/mark3labs/kit/pkg/kit"
)
@@ -63,8 +62,8 @@ func (r *sessionRegistry) create(ctx context.Context, cwd string) (*acpSession,
// 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.
if kitInstance.Extensions().HasExtensions() {
kitInstance.Extensions().SetContext(extensions.Context{
if kitInstance.HasExtensions() {
kitInstance.SetExtensionContext(extensions.Context{
SessionID: sessionID,
CWD: cwd,
Model: kitInstance.GetModelString(),
@@ -122,51 +121,82 @@ func (r *sessionRegistry) create(ctx context.Context, cwd string) (*acpSession,
MessageCount: s.MessageCount,
}
},
GetMessages: func() []extensions.SessionMessage { return kitInstance.Extensions().GetSessionMessages() },
GetSessionPath: func() string { return kitInstance.GetSessionPath() },
GetMessages: func() []extensions.SessionMessage { return kitInstance.GetSessionMessages() },
GetSessionPath: func() string { return kitInstance.GetSessionFilePath() },
AppendEntry: func(entryType, data string) (string, error) {
return kitInstance.Extensions().AppendEntry(entryType, data)
return kitInstance.AppendExtensionEntry(entryType, data)
},
GetEntries: func(entryType string) []extensions.ExtensionEntry {
return kitInstance.Extensions().GetEntries(entryType)
return kitInstance.GetExtensionEntries(entryType)
},
// 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) },
GetOption: func(name string) string { return kitInstance.GetExtensionOption(name) },
SetOption: func(name, value string) { kitInstance.SetExtensionOption(name, value) },
SetModel: func(modelString string) error {
previousModel := kitInstance.Extensions().GetContext().Model
previousModel := kitInstance.GetExtensionContext().Model
if err := kitInstance.SetModel(context.Background(), modelString); err != nil {
return err
}
kitInstance.Extensions().UpdateContextModel(modelString)
kitInstance.Extensions().EmitModelChange(modelString, previousModel, "extension")
kitInstance.UpdateExtensionContextModel(modelString)
kitInstance.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) },
EmitCustomEvent: func(name, data string) { kitInstance.EmitExtensionCustomEvent(name, data) },
GetAllTools: func() []extensions.ToolInfo { return kitInstance.GetExtensionToolInfos() },
SetActiveTools: func(names []string) { kitInstance.SetExtensionActiveTools(names) },
// 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 extbridge.SpawnSubagent(context.Background(), kitInstance, config)
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: result.Error,
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)
renderer := kitInstance.GetExtensionMessageRenderer(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() },
ReloadExtensions: func() error { return kitInstance.ReloadExtensions() },
})
kitInstance.Extensions().EmitSessionStart()
kitInstance.EmitSessionStart()
}
sess := &acpSession{
@@ -202,20 +232,6 @@ 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()
@@ -239,3 +255,40 @@ 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{}
}
}
+193 -799
View File
File diff suppressed because it is too large Load Diff
-302
View File
@@ -1,302 +0,0 @@
package agent
import (
"context"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"charm.land/fantasy"
"github.com/mark3labs/kit/internal/config"
)
// mockModel is a minimal LanguageModel that satisfies the interface
// without making real API calls. Used to test tool management wiring.
type mockModel struct{}
func (m *mockModel) Generate(_ context.Context, _ fantasy.Call) (*fantasy.Response, error) {
return &fantasy.Response{}, nil
}
func (m *mockModel) Stream(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
return nil, nil
}
func (m *mockModel) GenerateObject(_ context.Context, _ fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
return &fantasy.ObjectResponse{}, nil
}
func (m *mockModel) StreamObject(_ context.Context, _ fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) {
return nil, nil
}
func (m *mockModel) Provider() string { return "mock" }
func (m *mockModel) Model() string { return "mock-model" }
// testdataDir returns the absolute path to the tools testdata directory.
func testdataDir(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("cannot determine test file path")
}
return filepath.Join(filepath.Dir(file), "..", "tools", "testdata")
}
// echoServerConfig returns an MCPServerConfig for the test echo MCP server.
func echoServerConfig(t *testing.T) config.MCPServerConfig {
t.Helper()
script := filepath.Join(testdataDir(t), "echo_server.py")
if _, err := os.Stat(script); err != nil {
t.Skipf("echo_server.py not found: %v", err)
}
return config.MCPServerConfig{
Command: []string{"python3", script},
}
}
// mockAuthHandler is a minimal MCPAuthHandler for testing that auth handler
// propagation works without requiring a real OAuth server.
type mockAuthHandler struct {
redirectURI string
}
func (h *mockAuthHandler) RedirectURI() string { return h.redirectURI }
func (h *mockAuthHandler) HandleAuth(_ context.Context, _ string, _ string) (string, error) {
return "", nil
}
// newTestAgent creates a minimal Agent with a mock model and no core tools,
// suitable for testing MCP server management without an API key.
func newTestAgent() *Agent {
model := &mockModel{}
a := &Agent{
model: model,
coreTools: nil,
extraTools: nil,
maxSteps: 10,
systemPrompt: "test",
fantasyAgent: fantasy.NewAgent(model),
}
return a
}
func TestAgent_AddMCPServer(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
a := newTestAgent()
defer func() { _ = a.Close() }()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cfg := echoServerConfig(t)
// Initially no MCP tools.
if a.GetMCPToolCount() != 0 {
t.Fatalf("Expected 0 MCP tools initially, got %d", a.GetMCPToolCount())
}
// Add a server.
count, err := a.AddMCPServer(ctx, "echo", cfg)
if err != nil {
t.Fatalf("AddMCPServer failed: %v", err)
}
if count != 2 {
t.Errorf("Expected 2 tools, got %d", count)
}
// Verify tools are in the agent's tool list.
if a.GetMCPToolCount() != 2 {
t.Errorf("Expected 2 MCP tools, got %d", a.GetMCPToolCount())
}
allTools := a.GetTools()
toolNames := make(map[string]bool)
for _, tool := range allTools {
toolNames[tool.Info().Name] = true
}
if !toolNames["echo__echo"] {
t.Error("Expected tool 'echo__echo' in agent tools")
}
if !toolNames["echo__greet"] {
t.Error("Expected tool 'echo__greet' in agent tools")
}
// Verify loaded server names.
names := a.GetLoadedServerNames()
found := false
for _, n := range names {
if n == "echo" {
found = true
}
}
if !found {
t.Errorf("Expected 'echo' in loaded server names: %v", names)
}
}
func TestAgent_RemoveMCPServer(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
a := newTestAgent()
defer func() { _ = a.Close() }()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cfg := echoServerConfig(t)
// Add then remove.
_, err := a.AddMCPServer(ctx, "echo", cfg)
if err != nil {
t.Fatalf("AddMCPServer failed: %v", err)
}
err = a.RemoveMCPServer("echo")
if err != nil {
t.Fatalf("RemoveMCPServer failed: %v", err)
}
// Verify tools removed.
if a.GetMCPToolCount() != 0 {
t.Errorf("Expected 0 MCP tools after removal, got %d", a.GetMCPToolCount())
}
// Verify agent's tool list has no MCP tools.
for _, tool := range a.GetTools() {
if strings.Contains(tool.Info().Name, "echo__") {
t.Errorf("Found leftover tool after removal: %s", tool.Info().Name)
}
}
}
func TestAgent_RemoveMCPServer_NoToolManager(t *testing.T) {
a := newTestAgent()
defer func() { _ = a.Close() }()
err := a.RemoveMCPServer("nonexistent")
if err == nil {
t.Fatal("Expected error when no tool manager exists")
}
if !strings.Contains(err.Error(), "no MCP servers loaded") {
t.Errorf("Expected 'no MCP servers loaded' error, got: %v", err)
}
}
func TestAgent_AddMCPServer_CreatesToolManager(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
a := newTestAgent()
defer func() { _ = a.Close() }()
// Initially no tool manager.
if a.GetMCPToolManager() != nil {
t.Fatal("Expected nil tool manager initially")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cfg := echoServerConfig(t)
_, err := a.AddMCPServer(ctx, "echo", cfg)
if err != nil {
t.Fatalf("AddMCPServer failed: %v", err)
}
// Tool manager should now exist.
if a.GetMCPToolManager() == nil {
t.Fatal("Expected tool manager to be created by AddMCPServer")
}
}
func TestAgent_AddRemoveAdd_MCP(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
a := newTestAgent()
defer func() { _ = a.Close() }()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cfg := echoServerConfig(t)
// Add → Remove → Add cycle.
_, err := a.AddMCPServer(ctx, "echo", cfg)
if err != nil {
t.Fatalf("First add failed: %v", err)
}
err = a.RemoveMCPServer("echo")
if err != nil {
t.Fatalf("Remove failed: %v", err)
}
count, err := a.AddMCPServer(ctx, "echo", cfg)
if err != nil {
t.Fatalf("Re-add failed: %v", err)
}
if count != 2 {
t.Errorf("Expected 2 tools on re-add, got %d", count)
}
if a.GetMCPToolCount() != 2 {
t.Errorf("Expected 2 MCP tools after re-add, got %d", a.GetMCPToolCount())
}
}
// TestAgent_AddMCPServer_InheritsAuthHandler verifies that AddMCPServer()
// propagates the agent's authHandler and tokenStoreFactory to a newly created
// MCPToolManager (fix for issue #3).
func TestAgent_AddMCPServer_InheritsAuthHandler(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
handler := &mockAuthHandler{redirectURI: "http://localhost:9999/oauth/callback"}
model := &mockModel{}
a := &Agent{
model: model,
coreTools: nil,
extraTools: nil,
maxSteps: 10,
systemPrompt: "test",
fantasyAgent: fantasy.NewAgent(model),
authHandler: handler,
tokenStoreFactory: nil, // nil is fine; we just test authHandler propagation
}
defer func() { _ = a.Close() }()
// Initially no tool manager.
if a.GetMCPToolManager() != nil {
t.Fatal("Expected nil tool manager initially")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cfg := echoServerConfig(t)
_, err := a.AddMCPServer(ctx, "echo", cfg)
if err != nil {
t.Fatalf("AddMCPServer failed: %v", err)
}
// Tool manager should now exist and have the auth handler set.
tm := a.GetMCPToolManager()
if tm == nil {
t.Fatal("Expected tool manager to be created by AddMCPServer")
}
// Verify the auth handler was propagated by checking the field directly.
if tm.GetAuthHandler() == nil {
t.Fatal("Expected auth handler to be propagated to tool manager")
}
}
-84
View File
@@ -1,84 +0,0 @@
package agent
import (
"charm.land/fantasy"
"charm.land/fantasy/providers/anthropic"
)
// cacheControlOptions returns provider options for Anthropic cache control.
// This is used at the message level to avoid type conflicts with provider-level options.
func cacheControlOptions() fantasy.ProviderOptions {
return anthropic.NewProviderCacheControlOptions(&anthropic.ProviderCacheControlOptions{
CacheControl: anthropic.CacheControl{
Type: "ephemeral",
},
})
}
// applyCacheControlToMessages adds cache control to specific messages.
// Anthropic allows max 4 cache blocks per request.
// Counts existing cache blocks and only adds new ones up to the limit.
func applyCacheControlToMessages(messages []fantasy.Message) []fantasy.Message {
if len(messages) == 0 {
return messages
}
// Make a copy to avoid modifying the original slice
result := make([]fantasy.Message, len(messages))
copy(result, messages)
cacheOpts := cacheControlOptions()
maxCacheBlocks := 4
// Helper to check if message already has cache control
hasCache := func(msg fantasy.Message) bool {
if msg.ProviderOptions == nil {
return false
}
if _, ok := msg.ProviderOptions["anthropic"]; ok {
return true
}
return false
}
// Count existing cache blocks
existingCacheCount := 0
for _, msg := range result {
if hasCache(msg) {
existingCacheCount++
}
}
// If we're already at or over the limit, don't add more
if existingCacheCount >= maxCacheBlocks {
return result
}
// How many new cache blocks can we add?
remaining := maxCacheBlocks - existingCacheCount
// First: find and cache the last system message (most important)
lastSystemIdx := -1
for i, msg := range result {
if msg.Role == fantasy.MessageRoleSystem {
lastSystemIdx = i
}
}
if lastSystemIdx >= 0 && remaining > 0 && !hasCache(result[lastSystemIdx]) {
result[lastSystemIdx].ProviderOptions = cacheOpts
remaining--
}
// Second: cache the most recent messages (up to remaining limit)
// Work backwards from the end to prioritize recent context
for i := len(result) - 1; i >= 0 && remaining > 0; i-- {
if hasCache(result[i]) {
continue
}
result[i].ProviderOptions = cacheOpts
remaining--
}
return result
}
+10 -30
View File
@@ -36,28 +36,13 @@ type AgentCreationOptions struct {
SpinnerFunc SpinnerFunc // Function to show spinner (provided by caller)
// DebugLogger is an optional logger for debugging MCP communications
DebugLogger tools.DebugLogger // Optional debug logger
// AuthHandler handles OAuth authorization for remote MCP servers
AuthHandler tools.MCPAuthHandler
// TokenStoreFactory, if non-nil, creates a custom token store for each
// remote MCP server's OAuth tokens. When nil, the default file-based
// token store is used.
TokenStoreFactory tools.TokenStoreFactory
// CoreTools overrides the default core tool set. If empty, core.AllTools()
// is used.
CoreTools []fantasy.AgentTool
// DisableCoreTools, when true, prevents loading any core tools.
// If both DisableCoreTools is true and CoreTools is empty, the agent
// will have no tools (useful for simple chat completions).
DisableCoreTools bool
// ToolWrapper wraps the combined tool list before agent creation.
// ToolWrapper wraps the combined tool list before Fantasy agent creation.
ToolWrapper func([]fantasy.AgentTool) []fantasy.AgentTool
// ExtraTools are additional tools to include (e.g. from extensions).
ExtraTools []fantasy.AgentTool
// 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.
@@ -65,20 +50,15 @@ type AgentCreationOptions struct {
// Returns the created agent or an error if creation fails.
func CreateAgent(ctx context.Context, opts *AgentCreationOptions) (*Agent, error) {
agentConfig := &AgentConfig{
ModelConfig: opts.ModelConfig,
MCPConfig: opts.MCPConfig,
SystemPrompt: opts.SystemPrompt,
MaxSteps: opts.MaxSteps,
StreamingEnabled: opts.StreamingEnabled,
DebugLogger: opts.DebugLogger,
AuthHandler: opts.AuthHandler,
TokenStoreFactory: opts.TokenStoreFactory,
CoreTools: opts.CoreTools,
DisableCoreTools: opts.DisableCoreTools,
ToolWrapper: opts.ToolWrapper,
ExtraTools: opts.ExtraTools,
OnMCPServerLoaded: opts.OnMCPServerLoaded,
MCPTaskConfig: opts.MCPTaskConfig,
ModelConfig: opts.ModelConfig,
MCPConfig: opts.MCPConfig,
SystemPrompt: opts.SystemPrompt,
MaxSteps: opts.MaxSteps,
StreamingEnabled: opts.StreamingEnabled,
DebugLogger: opts.DebugLogger,
CoreTools: opts.CoreTools,
ToolWrapper: opts.ToolWrapper,
ExtraTools: opts.ExtraTools,
}
var agent *Agent
-88
View File
@@ -1,88 +0,0 @@
package agent
import (
"context"
"fmt"
"charm.land/fantasy"
"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
exec mcpExecutor
providerOptions fantasy.ProviderOptions
}
// Info returns the fantasy tool info including name, description, and parameter schema.
func (t *mcpAgentTool) Info() fantasy.ToolInfo {
return fantasy.ToolInfo{
Name: t.tool.Name,
Description: t.tool.Description,
Parameters: t.tool.Parameters,
Required: t.tool.Required,
}
}
// 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.exec.ExecuteTool(ctx, t.tool.Name, call.Input)
if err != nil {
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 {
return fantasy.NewTextErrorResponse(result.Content), nil
}
return fantasy.NewTextResponse(result.Content), nil
}
// ProviderOptions returns provider-specific options for this tool.
func (t *mcpAgentTool) ProviderOptions() fantasy.ProviderOptions {
return t.providerOptions
}
// SetProviderOptions sets provider-specific options for this tool.
func (t *mcpAgentTool) SetProviderOptions(opts fantasy.ProviderOptions) {
t.providerOptions = opts
}
// mcpToolsToAgentTools converts a slice of MCPTool to fantasy.AgentTool
// implementations that route execution through the MCPToolManager.
func mcpToolsToAgentTools(mcpTools []tools.MCPTool, manager *tools.MCPToolManager) []fantasy.AgentTool {
agentTools := make([]fantasy.AgentTool, len(mcpTools))
for i, t := range mcpTools {
agentTools[i] = &mcpAgentTool{
tool: t,
exec: manager,
}
}
return agentTools
}
-158
View File
@@ -1,158 +0,0 @@
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)
}
}
+4 -15
View File
@@ -1,17 +1,6 @@
package agent
import (
"context"
"charm.land/fantasy"
)
// SteerMessage carries a steering prompt and optional file attachments
// (e.g. clipboard images) through the steer channel.
type SteerMessage struct {
Text string
Files []fantasy.FilePart
}
import "context"
// steerChKey is the context key for the steer channel.
type steerChKey struct{}
@@ -22,7 +11,7 @@ type steerConsumedKey struct{}
// ContextWithSteerCh returns a new context with the steer channel attached.
// The agent's PrepareStep function checks this channel between steps and
// injects any pending steer messages as user messages before the next LLM call.
func ContextWithSteerCh(ctx context.Context, ch <-chan SteerMessage) context.Context {
func ContextWithSteerCh(ctx context.Context, ch <-chan string) context.Context {
return context.WithValue(ctx, steerChKey{}, ch)
}
@@ -34,8 +23,8 @@ func ContextWithSteerConsumed(ctx context.Context, fn func(count int)) context.C
}
// steerChFromContext extracts the steer channel from the context, or nil.
func steerChFromContext(ctx context.Context) <-chan SteerMessage {
ch, _ := ctx.Value(steerChKey{}).(<-chan SteerMessage)
func steerChFromContext(ctx context.Context) <-chan string {
ch, _ := ctx.Value(steerChKey{}).(<-chan string)
return ch
}
+67 -562
View File
@@ -3,11 +3,8 @@ package app
import (
"context"
"fmt"
"log"
"os"
"sync"
"sync/atomic"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/fantasy"
@@ -20,7 +17,7 @@ import (
// queueItem holds a prompt and optional image attachments for the execution queue.
type queueItem struct {
Prompt string
Files []kit.LLMFilePart
Files []fantasy.FilePart
}
// App is the application-layer orchestrator. It owns the agentic loop,
@@ -69,27 +66,11 @@ type App struct {
// rootCtx/rootCancel are used to signal shutdown to all goroutines.
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
// 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.
// initialMessages may be nil or empty for a fresh session.
func New(opts Options, initialMessages []kit.LLMMessage) *App {
func New(opts Options, initialMessages []fantasy.Message) *App {
rootCtx, rootCancel := context.WithCancel(context.Background())
return &App{
opts: opts,
@@ -133,8 +114,9 @@ func (a *App) Run(prompt string) int {
// If the app is idle the prompt executes immediately; otherwise it is queued.
// Returns the current queue depth (0 = started immediately, >0 = queued).
//
// Satisfies ui.AppController.
func (a *App) RunWithFiles(prompt string, files []kit.LLMFilePart) int {
// Satisfies ui.AppController (via RunWithImages which converts ImageAttachment
// to fantasy.FilePart).
func (a *App) RunWithFiles(prompt string, files []fantasy.FilePart) int {
a.mu.Lock()
if a.closed {
@@ -169,24 +151,6 @@ func (a *App) CancelCurrentStep() {
cancel()
}
// IsBusy returns true when the agent is currently processing a turn.
func (a *App) IsBusy() bool {
a.mu.Lock()
defer a.mu.Unlock()
return a.busy
}
// Abort cancels the current agent step (if running) and clears the queue.
// Unlike InterruptAndSend, no new message is injected — the agent simply
// stops. Safe to call when idle (no-op).
func (a *App) Abort() {
a.mu.Lock()
a.queue = a.queue[:0]
cancel := a.cancelStep
a.mu.Unlock()
cancel()
}
// QueueLength returns the number of prompts currently waiting in the queue.
//
// Satisfies ui.AppController.
@@ -212,15 +176,6 @@ func (a *App) QueueLength() int {
//
// Satisfies ui.AppController.
func (a *App) Steer(prompt string) int {
return a.SteerWithFiles(prompt, nil)
}
// SteerWithFiles injects a steering message with optional file attachments
// (e.g. pasted images) into the currently running agent turn. Behaves like
// Steer but includes file parts alongside the text.
//
// Satisfies ui.AppController.
func (a *App) SteerWithFiles(prompt string, files []kit.LLMFilePart) int {
a.mu.Lock()
if a.closed {
@@ -229,8 +184,8 @@ func (a *App) SteerWithFiles(prompt string, files []kit.LLMFilePart) int {
}
if !a.busy {
// Not busy — start immediately, same as RunWithFiles().
item := queueItem{Prompt: prompt, Files: files}
// Not busy — start immediately, same as Run().
item := queueItem{Prompt: prompt}
a.busy = true
a.wg.Add(1)
a.mu.Unlock()
@@ -245,7 +200,7 @@ func (a *App) SteerWithFiles(prompt string, files []kit.LLMFilePart) int {
// execution, before next LLM call). If PrepareStep doesn't fire
// (text-only response), drainQueue will pick it up after the turn.
if a.opts.Kit != nil {
a.opts.Kit.InjectSteerWithFiles(prompt, files)
a.opts.Kit.InjectSteer(prompt)
}
return 1
}
@@ -304,17 +259,6 @@ func (a *App) ClearMessages() {
}
}
// ReloadMessagesFromTree clears the in-memory message store and reloads it
// from the tree session's current branch. Unlike ClearMessages, this does NOT
// reset the tree session's leaf pointer. Used after Branch() to sync the
// store with the new branch position.
func (a *App) ReloadMessagesFromTree() {
a.store.Clear()
if a.opts.TreeSession != nil {
a.store.Replace(a.opts.TreeSession.GetLLMMessages())
}
}
// GetTreeSession returns the tree session manager, or nil if not configured.
func (a *App) GetTreeSession() *session.TreeManager {
return a.opts.TreeSession
@@ -336,7 +280,7 @@ func (a *App) SwitchTreeSession(ts *session.TreeManager) {
// Reload messages from new session.
a.store.Clear()
if ts != nil {
a.store.Replace(ts.GetLLMMessages())
a.store.Replace(ts.GetFantasyMessages())
}
}
@@ -347,12 +291,12 @@ func (a *App) SwitchTreeSession(ts *session.TreeManager) {
//
// Satisfies ui.AppController.
func (a *App) AddContextMessage(text string) {
kitMsg := fantasy.NewUserMessage(text)
a.store.Add(kitMsg)
msg := fantasy.NewUserMessage(text)
a.store.Add(msg)
// Persist to tree session if active.
if ts := a.opts.TreeSession; ts != nil {
_, _ = ts.AppendLLMMessage(fantasy.NewUserMessage(text))
_, _ = ts.AppendFantasyMessage(msg)
}
}
@@ -363,10 +307,6 @@ 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()
@@ -388,16 +328,11 @@ func (a *App) CompactConversation(customInstructions string) error {
go func() {
defer a.wg.Done()
defer a.releaseBusyAfterCompact()
// Subscribe to SDK events for streaming compaction summary to the TUI.
sendFn := func(msg tea.Msg) {
if a.program != nil {
a.program.Send(msg)
}
}
unsub := a.subscribeSDKEvents(sendFn, nil)
defer unsub()
defer func() {
a.mu.Lock()
a.busy = false
a.mu.Unlock()
}()
result, err := a.opts.Kit.Compact(a.rootCtx, nil, customInstructions)
if err != nil {
@@ -411,7 +346,7 @@ func (a *App) CompactConversation(customInstructions string) error {
// Sync in-memory store with the compacted session.
if a.opts.TreeSession != nil {
a.store.Replace(a.opts.TreeSession.GetLLMMessages())
a.store.Replace(a.opts.TreeSession.GetFantasyMessages())
}
a.sendEvent(CompactCompleteEvent{
@@ -424,152 +359,6 @@ func (a *App) CompactConversation(customInstructions string) error {
return nil
}
// 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 {
a.mu.Unlock()
return fmt.Errorf("app is closed")
}
if a.busy {
a.mu.Unlock()
return fmt.Errorf("cannot compact while the agent is working")
}
if a.opts.Kit == nil {
a.mu.Unlock()
return fmt.Errorf("SDK instance not available")
}
a.busy = true
a.wg.Add(1)
a.mu.Unlock()
go func() {
defer a.wg.Done()
defer a.releaseBusyAfterCompact()
// Subscribe to SDK events for streaming compaction summary to the TUI.
sendFn := func(msg tea.Msg) {
if a.program != nil {
a.program.Send(msg)
}
}
unsub := a.subscribeSDKEvents(sendFn, nil)
defer unsub()
result, err := a.opts.Kit.Compact(a.rootCtx, nil, customInstructions)
if err != nil {
a.sendEvent(CompactErrorEvent{Err: err})
if onError != nil {
onError(err.Error())
}
return
}
if result == nil {
a.sendEvent(CompactErrorEvent{Err: fmt.Errorf("nothing to compact")})
if onError != nil {
onError("nothing to compact")
}
return
}
// Sync in-memory store with the compacted session.
if a.opts.TreeSession != nil {
a.store.Replace(a.opts.TreeSession.GetLLMMessages())
}
a.sendEvent(CompactCompleteEvent{
Summary: result.Summary,
OriginalTokens: result.OriginalTokens,
CompactedTokens: result.CompactedTokens,
MessagesRemoved: result.MessagesRemoved,
})
if onComplete != nil {
onComplete()
}
}()
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
// --------------------------------------------------------------------------
@@ -578,12 +367,6 @@ func (a *App) releaseBusyAfterCompact() {
// response text to stdout. No intermediate events are emitted. Blocks until
// the step completes or ctx is cancelled.
func (a *App) RunOnce(ctx context.Context, prompt string) error {
return a.RunOnceWithFiles(ctx, prompt, nil)
}
// RunOnceWithFiles executes a single agent step synchronously with optional
// multimodal file attachments. Prints the response to stdout and returns.
func (a *App) RunOnceWithFiles(ctx context.Context, prompt string, files []kit.LLMFilePart) error {
stepCtx, cancel := context.WithCancel(ctx)
defer cancel()
@@ -591,7 +374,7 @@ func (a *App) RunOnceWithFiles(ctx context.Context, prompt string, files []kit.L
a.cancelStep = cancel
a.mu.Unlock()
result, err := a.executeStep(stepCtx, prompt, nil, files)
result, err := a.executeStep(stepCtx, prompt, nil, nil)
if err != nil {
return err
}
@@ -606,12 +389,6 @@ func (a *App) RunOnceWithFiles(ctx context.Context, prompt string, files []kit.L
// full TurnResult without printing anything. This is used by --json mode to
// capture structured output for serialization.
func (a *App) RunOnceResult(ctx context.Context, prompt string) (*kit.TurnResult, error) {
return a.RunOnceResultWithFiles(ctx, prompt, nil)
}
// RunOnceResultWithFiles executes a single agent step synchronously with
// optional multimodal file attachments and returns the full TurnResult.
func (a *App) RunOnceResultWithFiles(ctx context.Context, prompt string, files []kit.LLMFilePart) (*kit.TurnResult, error) {
stepCtx, cancel := context.WithCancel(ctx)
defer cancel()
@@ -619,7 +396,7 @@ func (a *App) RunOnceResultWithFiles(ctx context.Context, prompt string, files [
a.cancelStep = cancel
a.mu.Unlock()
return a.executeStep(stepCtx, prompt, nil, files)
return a.executeStep(stepCtx, prompt, nil, nil)
}
// RunOnceWithDisplay executes a single agent step synchronously, sending
@@ -633,12 +410,6 @@ func (a *App) RunOnceResultWithFiles(ctx context.Context, prompt string, files [
//
// Blocks until the step completes or ctx is cancelled.
func (a *App) RunOnceWithDisplay(ctx context.Context, prompt string, eventFn func(tea.Msg)) error {
return a.RunOnceWithDisplayAndFiles(ctx, prompt, eventFn, nil)
}
// RunOnceWithDisplayAndFiles executes a single agent step synchronously with
// optional multimodal file attachments, sending intermediate display events.
func (a *App) RunOnceWithDisplayAndFiles(ctx context.Context, prompt string, eventFn func(tea.Msg), files []kit.LLMFilePart) error {
stepCtx, cancel := context.WithCancel(ctx)
defer cancel()
@@ -646,7 +417,7 @@ func (a *App) RunOnceWithDisplayAndFiles(ctx context.Context, prompt string, eve
a.cancelStep = cancel
a.mu.Unlock()
result, err := a.executeStep(stepCtx, prompt, eventFn, files)
result, err := a.executeStep(stepCtx, prompt, eventFn, nil)
if err != nil {
return err
}
@@ -712,10 +483,11 @@ func (a *App) drainQueue(first queueItem) {
a.mu.Lock()
items = append(items, a.queue...)
a.queue = a.queue[:0] // Clear the queue
queueLen := len(a.queue)
a.mu.Unlock()
// Notify UI: all queued messages have been consumed into this batch.
a.sendEvent(QueueUpdatedEvent{Length: 0})
// Send queue updated event (queue is now empty)
a.sendEvent(QueueUpdatedEvent{Length: queueLen})
// Process all collected items as a single batch
a.runQueueBatch(items)
@@ -728,8 +500,8 @@ func (a *App) drainQueue(first queueItem) {
if leftover := a.opts.Kit.DrainSteer(); len(leftover) > 0 {
a.mu.Lock()
steerItems := make([]queueItem, len(leftover))
for i, sm := range leftover {
steerItems[i] = queueItem{Prompt: sm.Text, Files: sm.Files}
for i, text := range leftover {
steerItems[i] = queueItem{Prompt: text}
}
a.queue = append(steerItems, a.queue...)
a.mu.Unlock()
@@ -748,11 +520,6 @@ func (a *App) drainQueue(first queueItem) {
}
a.mu.Unlock()
if hasMore {
// Notify UI: these newly queued messages have been consumed into the next batch.
a.sendEvent(QueueUpdatedEvent{Length: 0})
}
if !hasMore {
// No more items, we're done
break
@@ -800,7 +567,7 @@ func (a *App) runQueueBatch(items []queueItem) {
// call/result pairs; only the in-progress message or tool
// call is discarded. Sync the in-memory store to match.
if ts := a.opts.TreeSession; ts != nil {
a.store.Replace(ts.GetLLMMessages())
a.store.Replace(ts.GetFantasyMessages())
}
a.sendEvent(StepCancelledEvent{})
return
@@ -819,7 +586,7 @@ func (a *App) runQueueBatch(items []queueItem) {
// executeStep runs a single agentic step by delegating to the SDK's
// PromptResult() (or PromptResultWithFiles for multimodal), which handles
// session persistence, hooks, extension events, and the generation loop.
func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.Msg), files []kit.LLMFilePart) (*kit.TurnResult, error) {
func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.Msg), files []fantasy.FilePart) (*kit.TurnResult, error) {
// Test hook: bypass SDK entirely.
if a.opts.PromptFunc != nil {
return a.opts.PromptFunc(ctx, prompt)
@@ -831,10 +598,9 @@ func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.M
}
}
// Subscribe to SDK events for TUI rendering and per-step usage updates.
// The subscription is temporary — it lives only for the duration of this step.
var sawStepUsage atomic.Bool
unsub := a.subscribeSDKEvents(sendFn, &sawStepUsage)
// Subscribe to SDK events for TUI rendering. The subscription is
// temporary — it lives only for the duration of this step.
unsub := a.subscribeSDKEvents(sendFn)
defer unsub()
// Show spinner while the agent works.
@@ -854,9 +620,8 @@ func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.M
// Sync in-memory store with the SDK's authoritative conversation.
a.store.Replace(result.Messages)
// Update usage tracker. If per-step usage was already recorded from
// StepUsageEvent callbacks, avoid double-counting totals.
a.updateUsageFromTurnResult(result, prompt, sawStepUsage.Load())
// Update usage tracker.
a.updateUsageFromTurnResult(result, prompt)
return result, nil
}
@@ -880,10 +645,9 @@ func (a *App) executeBatch(ctx context.Context, items []queueItem, eventFn func(
}
}
// Subscribe to SDK events for TUI rendering and per-step usage updates.
// The subscription is temporary — it lives only for the duration of this step.
var sawStepUsage atomic.Bool
unsub := a.subscribeSDKEvents(sendFn, &sawStepUsage)
// Subscribe to SDK events for TUI rendering. The subscription is
// temporary — it lives only for the duration of this step.
unsub := a.subscribeSDKEvents(sendFn)
defer unsub()
// Show spinner while the agent works.
@@ -916,8 +680,8 @@ func (a *App) executeBatch(ctx context.Context, items []queueItem, eventFn func(
messages = append(messages, item.Prompt)
}
// File attachments are not supported in batch mode; fall back to
// processing only the first item that carries files.
// TODO: Handle file attachments in batch mode
// For now, files are ignored in batch mode (rare edge case)
if hasFiles {
// If files exist, fall back to processing just the first item with files
for _, item := range items {
@@ -938,10 +702,8 @@ func (a *App) executeBatch(ctx context.Context, items []queueItem, eventFn func(
// Sync in-memory store with the SDK's authoritative conversation.
a.store.Replace(result.Messages)
// Update usage tracker (using last item's prompt for fallback estimation).
// If per-step usage was already recorded from StepUsageEvent callbacks,
// avoid double-counting totals.
a.updateUsageFromTurnResult(result, items[len(items)-1].Prompt, sawStepUsage.Load())
// Update usage tracker (using last item's prompt for tracking).
a.updateUsageFromTurnResult(result, items[len(items)-1].Prompt)
return result, nil
}
@@ -958,10 +720,9 @@ func (a *App) sendEvent(msg tea.Msg) {
}
// subscribeSDKEvents registers temporary SDK event subscribers that convert
// SDK events to tea.Msg events and dispatch them via sendFn. When stepUsageSeen
// is provided, it is set to true after any non-zero StepUsageEvent is observed.
// Returns an unsubscribe function that removes all listeners.
func (a *App) subscribeSDKEvents(sendFn func(tea.Msg), stepUsageSeen *atomic.Bool) func() {
// SDK events to tea.Msg events and dispatch them via sendFn. Returns an
// unsubscribe function that removes all listeners.
func (a *App) subscribeSDKEvents(sendFn func(tea.Msg)) func() {
k := a.opts.Kit
var unsubs []func()
@@ -969,12 +730,6 @@ 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:
@@ -992,8 +747,6 @@ func (a *App) subscribeSDKEvents(sendFn func(tea.Msg), stepUsageSeen *atomic.Boo
sendFn(StreamChunkEvent{Content: ev.Chunk})
case kit.ReasoningDeltaEvent:
sendFn(ReasoningChunkEvent{Delta: ev.Delta})
case kit.ReasoningCompleteEvent:
sendFn(ReasoningCompleteEvent{})
case kit.ToolOutputEvent:
sendFn(ToolOutputEvent{
ToolCallID: ev.ToolCallID,
@@ -1003,24 +756,6 @@ func (a *App) subscribeSDKEvents(sendFn func(tea.Msg), stepUsageSeen *atomic.Boo
})
case kit.SteerConsumedEvent:
sendFn(SteerConsumedEvent{})
case kit.StepUsageEvent:
a.recordStepUsage(ev, stepUsageSeen, sendFn)
case kit.PasswordPromptEvent:
// Convert SDK PasswordPromptEvent to app PasswordPromptEvent
// The TUI will handle this and send the response back
responseCh := make(chan PasswordPromptResponse, 1)
sendFn(PasswordPromptEvent{
Prompt: ev.Prompt,
ResponseCh: responseCh,
})
// Wait for TUI response and forward to SDK
resp := <-responseCh
ev.ResponseCh <- kit.PasswordPromptResponse{
Password: resp.Password,
Cancelled: resp.Cancelled,
}
case kit.TurnEndEvent:
a.handleTurnEnd(ev, sendFn)
}
}))
@@ -1031,64 +766,6 @@ 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
@@ -1109,8 +786,7 @@ func (a *App) QuitFromExtension() {
// controls styling: "" for plain text, "info" for a system message block,
// "error" for an error block. In interactive mode it sends an
// ExtensionPrintEvent through the program so the TUI can render it with the
// appropriate renderer. In non-interactive mode it falls back to stderr with
// a level prefix so errors are distinguishable from plain output.
// appropriate renderer. In non-interactive mode it falls back to stdout.
func (a *App) PrintFromExtension(level, text string) {
a.mu.Lock()
prog := a.program
@@ -1119,16 +795,8 @@ func (a *App) PrintFromExtension(level, text string) {
prog.Send(ExtensionPrintEvent{Text: text, Level: level})
return
}
// Non-interactive fallback: write to stderr with a level prefix so that
// errors and info messages are distinguishable from plain output.
switch level {
case "error":
fmt.Fprintf(os.Stderr, "[ERROR] %s\n", text)
case "info":
fmt.Fprintf(os.Stderr, "[INFO] %s\n", text)
default:
fmt.Println(text)
}
// Non-interactive fallback: write directly to stdout.
fmt.Println(text)
}
// SetEditorTextFromExtension sends an EditorTextSetEvent to the TUI to
@@ -1156,73 +824,12 @@ func (a *App) NotifyModelChanged(provider, model string) {
// NotifyWidgetUpdate sends a WidgetUpdateEvent to the TUI so it re-renders
// 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.
func (a *App) NotifyWidgetUpdate() {
// Coalesce: only one pending update at a time.
if !a.widgetUpdatePending.CompareAndSwap(false, 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 {
// No program registered (non-interactive mode); clear the flag so
// future calls are never permanently blocked.
a.widgetUpdatePending.Store(false)
}
}
// NotifyContentReload sends a ContentReloadEvent to the TUI so it refreshes
// prompt templates and skills from their provider callbacks. Called by file
// watchers when .md/.txt files change in prompt or skill directories.
// In non-interactive mode this is a no-op.
func (a *App) NotifyContentReload() {
a.mu.Lock()
prog := a.program
a.mu.Unlock()
if prog != nil {
prog.Send(ContentReloadEvent{})
}
}
// NotifyMCPToolsReady sends an MCPToolsReadyEvent to the TUI so it refreshes
// tool names and MCP tool count from provider callbacks. Called when background
// MCP tool loading completes. In non-interactive mode this is a no-op.
func (a *App) NotifyMCPToolsReady() {
a.mu.Lock()
prog := a.program
a.mu.Unlock()
if prog != nil {
prog.Send(MCPToolsReadyEvent{})
}
}
// NotifyMCPServerLoaded sends an MCPServerLoadedEvent to the TUI so it can
// display a system message when a single MCP server finishes loading. Called
// per server as background MCP tool loading progresses.
func (a *App) NotifyMCPServerLoaded(serverName string, toolCount int, err error) {
a.mu.Lock()
prog := a.program
a.mu.Unlock()
if prog != nil {
prog.Send(MCPServerLoadedEvent{
ServerName: serverName,
ToolCount: toolCount,
Error: err,
})
}
}
@@ -1310,150 +917,48 @@ func (a *App) PrintBlockFromExtension(opts extensions.PrintBlockOpts) {
})
return
}
// Non-interactive fallback: render a simple framed block to stderr so
// it is visually distinct from plain stdout output.
// Non-interactive fallback.
if opts.Subtitle != "" {
fmt.Fprintf(os.Stderr, "--- %s ---\n%s\n", opts.Subtitle, opts.Text)
fmt.Printf("%s\n — %s\n", opts.Text, opts.Subtitle)
} else {
fmt.Fprintf(os.Stderr, "---\n%s\n---\n", opts.Text)
}
}
// 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.
//
// 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",
hasUsage, ev.InputTokens, ev.OutputTokens, ev.CacheReadTokens, ev.CacheWriteTokens)
}
if !hasUsage {
return
}
if stepUsageSeen != nil {
stepUsageSeen.Store(true)
}
if a.opts.UsageTracker == nil {
return
}
a.opts.UsageTracker.UpdateUsage(
int(ev.InputTokens),
int(ev.OutputTokens),
int(ev.CacheReadTokens),
int(ev.CacheWriteTokens),
)
// 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{})
fmt.Println(opts.Text)
}
}
// updateUsageFromTurnResult records token usage from an SDK TurnResult into the
// configured UsageTracker. Called once per turn after the turn completes.
//
// When sawStepUsage is true, totals were already accumulated incrementally via
// StepUsageEvent callbacks; in that case this method only updates context fill.
// Otherwise it falls back to TotalUsage from the API response.
//
// NOTE: We only use ACTUAL token counts from API responses for cost tracking.
// Estimation is never used for costs - only API-reported tokens are accurate.
func (a *App) updateUsageFromTurnResult(result *kit.TurnResult, userPrompt string, sawStepUsage bool) {
// Cost/token accumulation uses TotalUsage (sum across all tool-calling steps in
// the turn). Context-window fill uses FinalUsage.InputTokens only — that is the
// number of tokens sent to the model on the last API call, which equals the
// actual context window occupation (all accumulated messages + tool results).
// OutputTokens are not added here because they are the response length, not
// context fill.
func (a *App) updateUsageFromTurnResult(result *kit.TurnResult, userPrompt string) {
if a.opts.UsageTracker == nil || result == nil {
return
}
// Debug logging for token tracking
if a.opts.Debug {
if result.TotalUsage != nil {
log.Printf("[DEBUG] updateUsageFromTurnResult TotalUsage: input=%d output=%d cacheRead=%d cacheCreate=%d",
result.TotalUsage.InputTokens, result.TotalUsage.OutputTokens,
result.TotalUsage.CacheReadTokens, result.TotalUsage.CacheCreationTokens)
} else {
log.Printf("[DEBUG] updateUsageFromTurnResult: TotalUsage=nil")
}
if result.FinalUsage != nil {
log.Printf("[DEBUG] updateUsageFromTurnResult FinalUsage: input=%d output=%d cacheRead=%d cacheCreate=%d",
result.FinalUsage.InputTokens, result.FinalUsage.OutputTokens,
result.FinalUsage.CacheReadTokens, result.FinalUsage.CacheCreationTokens)
} else {
log.Printf("[DEBUG] updateUsageFromTurnResult: FinalUsage=nil")
}
log.Printf("[DEBUG] updateUsageFromTurnResult: sawStepUsage=%v", sawStepUsage)
}
// --- Accumulate cost/token totals for the session ---
// Only use actual API-reported tokens for cost tracking.
// If sawStepUsage is true, totals were already updated via StepUsageEvent.
// Check any token field > 0 (not just InputTokens) because cached prompts
// can result in InputTokens=0 while OutputTokens>0 (OpenAI-compatible behavior).
hasTotalUsage := result.TotalUsage != nil &&
(result.TotalUsage.InputTokens > 0 ||
result.TotalUsage.OutputTokens > 0 ||
result.TotalUsage.CacheReadTokens > 0 ||
result.TotalUsage.CacheCreationTokens > 0)
if a.opts.Debug {
log.Printf("[DEBUG] updateUsageFromTurnResult: hasTotalUsage=%v", hasTotalUsage)
}
if !sawStepUsage && hasTotalUsage {
if a.opts.Debug {
log.Printf("[DEBUG] updateUsageFromTurnResult: calling UpdateUsage input=%d output=%d cacheRead=%d cacheCreate=%d",
result.TotalUsage.InputTokens, result.TotalUsage.OutputTokens,
result.TotalUsage.CacheReadTokens, result.TotalUsage.CacheCreationTokens)
}
if result.TotalUsage != nil && result.TotalUsage.InputTokens > 0 {
a.opts.UsageTracker.UpdateUsage(
int(result.TotalUsage.InputTokens),
int(result.TotalUsage.OutputTokens),
int(result.TotalUsage.CacheReadTokens),
int(result.TotalUsage.CacheCreationTokens),
)
} else {
// Provider didn't report token counts — fall back to character-based
// estimates so the footer shows something rather than nothing.
a.opts.UsageTracker.EstimateAndUpdateUsage(userPrompt, result.Response)
}
// --- Context window fill (drives the % bar) ---
// Calculate context fill from the LAST API call's usage. The context
// window is filled by everything sent to and received from the model:
//
// InputTokens — non-cached input (may be small with prompt caching)
// CacheReadTokens — input tokens served from cache
// CacheCreationTokens — input tokens written to cache this call
// OutputTokens — assistant output (becomes input next turn)
//
// With Anthropic prompt caching, InputTokens can drop to near-zero while
// CacheReadTokens holds the bulk of the context. We must sum all four to
// get the true context window utilization.
//
// We use FinalUsage (last step only), NOT TotalUsage, because TotalUsage
// sums across all tool-calling steps — and each step re-sends the full
// conversation, so TotalUsage massively overstates the actual window fill.
if result.FinalUsage != nil {
u := result.FinalUsage
contextFill := int(u.InputTokens) + int(u.CacheReadTokens) + int(u.CacheCreationTokens) + int(u.OutputTokens)
if contextFill > 0 {
if a.opts.Debug {
log.Printf("[DEBUG] updateUsageFromTurnResult: SetContextTokens=%d (Input=%d + CacheRead=%d + CacheCreate=%d + Output=%d)",
contextFill, u.InputTokens, u.CacheReadTokens, u.CacheCreationTokens, u.OutputTokens)
}
a.opts.UsageTracker.SetContextTokens(contextFill)
}
// Use FinalUsage.InputTokens: the input token count of the last API call
// equals the number of tokens currently occupying the context window.
// Adding OutputTokens would overstate fill since the response is not part
// of the context that was *sent* to the model.
if result.FinalUsage != nil && result.FinalUsage.InputTokens > 0 {
a.opts.UsageTracker.SetContextTokens(int(result.FinalUsage.InputTokens))
}
}
-480
View File
@@ -3,12 +3,10 @@ package app
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
tea "charm.land/bubbletea/v2"
kit "github.com/mark3labs/kit/pkg/kit"
)
@@ -16,47 +14,6 @@ import (
// Helpers
// --------------------------------------------------------------------------
type usageUpdaterStub struct {
mu sync.Mutex
updateCalls int
estimateCalls int
contextCalls int
lastUpdateInput int
lastUpdateOutput int
lastUpdateCacheRead int
lastUpdateCacheWrite int
lastContextTokens int
lastEstimateInput string
lastEstimateOutput string
}
func (s *usageUpdaterStub) UpdateUsage(inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens int) {
s.mu.Lock()
defer s.mu.Unlock()
s.updateCalls++
s.lastUpdateInput = inputTokens
s.lastUpdateOutput = outputTokens
s.lastUpdateCacheRead = cacheReadTokens
s.lastUpdateCacheWrite = cacheWriteTokens
}
func (s *usageUpdaterStub) EstimateAndUpdateUsage(inputText, outputText string) {
s.mu.Lock()
defer s.mu.Unlock()
s.estimateCalls++
s.lastEstimateInput = inputText
s.lastEstimateOutput = outputText
}
func (s *usageUpdaterStub) SetContextTokens(tokens int) {
s.mu.Lock()
defer s.mu.Unlock()
s.contextCalls++
s.lastContextTokens = tokens
}
// turnResult builds a minimal TurnResult with response text t.
func turnResult(t string) *kit.TurnResult {
return &kit.TurnResult{Response: t}
@@ -532,440 +489,3 @@ func TestQueueLength_reflects(t *testing.T) {
t.Fatalf("expected 3, got %d", got)
}
}
// TestRecordStepUsage_updatesTracker verifies that per-step usage updates are
// 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)
defer app.Close()
app.recordStepUsage(kit.StepUsageEvent{
InputTokens: 120,
OutputTokens: 45,
CacheReadTokens: 5,
CacheWriteTokens: 2,
}, nil, nil)
usage.mu.Lock()
defer usage.mu.Unlock()
if usage.updateCalls != 1 {
t.Fatalf("expected 1 update call, got %d", usage.updateCalls)
}
if usage.lastUpdateInput != 120 || usage.lastUpdateOutput != 45 || usage.lastUpdateCacheRead != 5 || usage.lastUpdateCacheWrite != 2 {
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 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)
}
}
// TestUpdateUsageFromTurnResult_skipsTotalsWhenStepUsageSeen ensures we avoid
// double-counting totals once StepUsageEvent-based updates were already applied.
func TestUpdateUsageFromTurnResult_skipsTotalsWhenStepUsageSeen(t *testing.T) {
usage := &usageUpdaterStub{}
app := New(Options{UsageTracker: usage}, nil)
defer app.Close()
app.updateUsageFromTurnResult(&kit.TurnResult{
Response: "ok",
TotalUsage: &kit.LLMUsage{
InputTokens: 999,
OutputTokens: 111,
CacheReadTokens: 7,
CacheCreationTokens: 3,
},
FinalUsage: &kit.LLMUsage{InputTokens: 456},
}, "prompt", true)
usage.mu.Lock()
defer usage.mu.Unlock()
if usage.updateCalls != 0 {
t.Fatalf("expected no total usage update when sawStepUsage=true, got %d", usage.updateCalls)
}
if usage.estimateCalls != 0 {
t.Fatalf("expected no estimate update when sawStepUsage=true, got %d", usage.estimateCalls)
}
// Context tokens should be InputTokens only (456)
if usage.contextCalls != 1 || usage.lastContextTokens != 456 {
t.Fatalf("expected final context tokens=456 (InputTokens only), got calls=%d tokens=%d", usage.contextCalls, usage.lastContextTokens)
}
}
// TestUpdateUsageFromTurnResult_recordsWhenInputTokensZero verifies that usage
// is recorded when InputTokens=0 but OutputTokens>0 (OpenAI-compatible cache behavior).
func TestUpdateUsageFromTurnResult_recordsWhenInputTokensZero(t *testing.T) {
usage := &usageUpdaterStub{}
app := New(Options{UsageTracker: usage}, nil)
defer app.Close()
// Simulate OpenAI-compatible behavior: all prompt tokens cached, InputTokens=0
app.updateUsageFromTurnResult(&kit.TurnResult{
Response: "ok",
TotalUsage: &kit.LLMUsage{
InputTokens: 0, // All cached - subtracted from prompt
OutputTokens: 150, // Actual generated tokens
CacheReadTokens: 500, // Cache hit
CacheCreationTokens: 0,
},
FinalUsage: &kit.LLMUsage{InputTokens: 0, OutputTokens: 150},
}, "prompt", false)
usage.mu.Lock()
defer usage.mu.Unlock()
if usage.updateCalls != 1 {
t.Fatalf("expected 1 update call when InputTokens=0 but OutputTokens>0, got %d", usage.updateCalls)
}
if usage.lastUpdateInput != 0 || usage.lastUpdateOutput != 150 {
t.Fatalf("expected input=0 output=150, got input=%d output=%d",
usage.lastUpdateInput, usage.lastUpdateOutput)
}
if usage.lastUpdateCacheRead != 500 {
t.Fatalf("expected cache_read=500, got %d", usage.lastUpdateCacheRead)
}
}
// TestUpdateUsageFromTurnResult_contextTokensUsesAllCategories verifies that
// context window fill uses all token categories from the final API call:
// InputTokens + CacheReadTokens + CacheCreationTokens + OutputTokens.
// With Anthropic prompt caching, InputTokens can be near-zero while
// CacheReadTokens holds the bulk of the context.
func TestUpdateUsageFromTurnResult_contextTokensUsesAllCategories(t *testing.T) {
usage := &usageUpdaterStub{}
app := New(Options{UsageTracker: usage}, nil)
defer app.Close()
app.updateUsageFromTurnResult(&kit.TurnResult{
Response: "ok",
TotalUsage: &kit.LLMUsage{
InputTokens: 3,
OutputTokens: 5,
CacheReadTokens: 0,
CacheCreationTokens: 4317,
},
FinalUsage: &kit.LLMUsage{
InputTokens: 3, // Non-cached input (small with caching)
OutputTokens: 5, // Assistant output
CacheReadTokens: 0, // No cache reads on first call
CacheCreationTokens: 4317, // System prompt + tools written to cache
},
}, "prompt", false)
usage.mu.Lock()
defer usage.mu.Unlock()
// Context tokens should be Input + CacheRead + CacheCreate + Output = 4325
expected := 3 + 0 + 4317 + 5
if usage.contextCalls != 1 || usage.lastContextTokens != expected {
t.Fatalf("expected context tokens=%d (all categories), got calls=%d tokens=%d",
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)
}
}
+3 -81
View File
@@ -1,6 +1,6 @@
package app
import kit "github.com/mark3labs/kit/pkg/kit"
import "charm.land/fantasy"
// StreamChunkEvent is sent by the app layer when a streaming text delta arrives
// from the LLM. Each chunk contains an incremental portion of the response.
@@ -16,11 +16,6 @@ type ReasoningChunkEvent struct {
Delta string
}
// ReasoningCompleteEvent is sent when reasoning/thinking is finished, after
// the last reasoning token has been processed. The TUI uses this to freeze
// the reasoning duration counter.
type ReasoningCompleteEvent struct{}
// ToolCallStartedEvent is sent when a tool call has been parsed and is about to execute.
// It carries the tool name and its arguments for display purposes.
type ToolCallStartedEvent struct {
@@ -32,36 +27,6 @@ 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 {
@@ -109,24 +74,6 @@ type ToolCallContentEvent struct {
Content string
}
// PasswordPromptEvent is sent when a sudo command needs a password.
// The TUI should display a password prompt overlay and send the result back.
type PasswordPromptEvent struct {
// Prompt is the message to display to the user.
Prompt string
// ResponseCh receives the password from the TUI.
// The TUI must send exactly one value.
ResponseCh chan<- PasswordPromptResponse
}
// PasswordPromptResponse carries the user's password input.
type PasswordPromptResponse struct {
// Password is the entered password.
Password string
// Cancelled is true if the user cancelled the prompt.
Cancelled bool
}
// ResponseCompleteEvent is sent when the LLM produces a final (non-streaming) response.
// In streaming mode, this may be empty if all content was delivered via StreamChunkEvents.
type ResponseCompleteEvent struct {
@@ -171,8 +118,8 @@ type SpinnerEvent struct {
// MessageCreatedEvent is sent when a new message is added to the message store.
// This allows the TUI to stay in sync with the conversation history.
type MessageCreatedEvent struct {
// Message is the message that was added to the store.
Message kit.LLMMessage
// Message is the fantasy message that was added to the store.
Message fantasy.Message
}
// CompactCompleteEvent is sent when a /compact operation finishes successfully.
@@ -210,36 +157,11 @@ 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.
type WidgetUpdateEvent struct{}
// ContentReloadEvent is sent when prompt templates or skills are reloaded
// from disk (e.g. by a file watcher detecting changes). The TUI refreshes
// its autocomplete entries and internal state from the provider callbacks.
type ContentReloadEvent struct{}
// MCPToolsReadyEvent is sent when background MCP tool loading completes.
// The TUI refreshes its tool names and MCP tool count from provider callbacks
// so that /tools and the startup info bar reflect the loaded MCP tools.
type MCPToolsReadyEvent struct{}
// MCPServerLoadedEvent is sent when a single MCP server finishes loading
// (successfully or with error). The TUI displays a system message so users
// see real-time progress as each server initializes.
type MCPServerLoadedEvent struct {
ServerName string
ToolCount int
Error error // nil on success
}
// EditorTextSetEvent is sent when an extension calls ctx.SetEditorText to
// pre-fill the input editor with text. The TUI handles this by setting the
// textarea content and moving the cursor to the end.
+9 -9
View File
@@ -3,14 +3,14 @@ package app
import (
"sync"
kit "github.com/mark3labs/kit/pkg/kit"
"charm.land/fantasy"
)
// MessageStore is a thread-safe in-memory store for the conversation history.
// On-disk persistence is handled by the TreeManager at the app/SDK layer.
type MessageStore struct {
mu sync.RWMutex
messages []kit.LLMMessage
messages []fantasy.Message
}
// NewMessageStore creates an empty MessageStore.
@@ -20,14 +20,14 @@ func NewMessageStore() *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 {
cp := make([]kit.LLMMessage, len(msgs))
func NewMessageStoreWithMessages(msgs []fantasy.Message) *MessageStore {
cp := make([]fantasy.Message, len(msgs))
copy(cp, msgs)
return &MessageStore{messages: cp}
}
// Add appends a single message to the store.
func (s *MessageStore) Add(msg kit.LLMMessage) {
func (s *MessageStore) Add(msg fantasy.Message) {
s.mu.Lock()
defer s.mu.Unlock()
s.messages = append(s.messages, msg)
@@ -36,22 +36,22 @@ func (s *MessageStore) Add(msg kit.LLMMessage) {
// Replace replaces the entire message history with the given slice. This is
// used after an agent step returns the full updated conversation (including
// tool calls and results).
func (s *MessageStore) Replace(msgs []kit.LLMMessage) {
func (s *MessageStore) Replace(msgs []fantasy.Message) {
s.mu.Lock()
defer s.mu.Unlock()
cp := make([]kit.LLMMessage, len(msgs))
cp := make([]fantasy.Message, len(msgs))
copy(cp, msgs)
s.messages = cp
}
// GetAll returns a snapshot copy of the current message slice.
// The returned slice is safe to modify without affecting the store.
func (s *MessageStore) GetAll() []kit.LLMMessage {
func (s *MessageStore) GetAll() []fantasy.Message {
s.mu.RLock()
defer s.mu.RUnlock()
cp := make([]kit.LLMMessage, len(s.messages))
cp := make([]fantasy.Message, len(s.messages))
copy(cp, s.messages)
return cp
}
+29 -32
View File
@@ -3,27 +3,17 @@ package app
import (
"testing"
kit "github.com/mark3labs/kit/pkg/kit"
"charm.land/fantasy"
)
// makeTextMsg builds a minimal kit.LLMMessage with the given role and text.
func makeTextMsg(role, text string) kit.LLMMessage {
return kit.LLMMessage{
Role: kit.LLMMessageRole(role),
Content: []kit.LLMMessagePart{kit.LLMTextPart{Text: text}},
// makeTextMsg builds a minimal fantasy.Message with a single TextPart.
func makeTextMsg(role, text string) fantasy.Message {
return fantasy.Message{
Role: fantasy.MessageRole(role),
Content: []fantasy.MessagePart{fantasy.TextPart{Text: text}},
}
}
// textOf extracts the plain text from an LLMMessage for assertions.
func textOf(msg kit.LLMMessage) string {
for _, part := range msg.Content {
if tp, ok := part.(kit.LLMTextPart); ok {
return tp.Text
}
}
return ""
}
// --------------------------------------------------------------------------
// NewMessageStore / NewMessageStoreWithMessages
// --------------------------------------------------------------------------
@@ -39,7 +29,7 @@ func TestNewMessageStore_empty(t *testing.T) {
}
func TestNewMessageStoreWithMessages_preloaded(t *testing.T) {
msgs := []kit.LLMMessage{
msgs := []fantasy.Message{
makeTextMsg("user", "hello"),
makeTextMsg("assistant", "hi"),
}
@@ -52,7 +42,7 @@ func TestNewMessageStoreWithMessages_preloaded(t *testing.T) {
// NewMessageStoreWithMessages must deep-copy the slice so that external
// modifications don't affect the store.
func TestNewMessageStoreWithMessages_isolatesInput(t *testing.T) {
msgs := []kit.LLMMessage{makeTextMsg("user", "hello")}
msgs := []fantasy.Message{makeTextMsg("user", "hello")}
s := NewMessageStoreWithMessages(msgs)
// Mutate the source slice.
@@ -62,8 +52,9 @@ func TestNewMessageStoreWithMessages_isolatesInput(t *testing.T) {
if len(got) != 1 {
t.Fatalf("expected 1 message, got %d", len(got))
}
if textOf(got[0]) != "hello" {
t.Fatalf("store was mutated by external slice change; got %q", textOf(got[0]))
tp, ok := got[0].Content[0].(fantasy.TextPart)
if !ok || tp.Text != "hello" {
t.Fatalf("store was mutated by external slice change; got %q", tp.Text)
}
}
@@ -89,8 +80,9 @@ func TestAdd_preservesOrder(t *testing.T) {
}
got := s.GetAll()
for i, expected := range texts {
if textOf(got[i]) != expected {
t.Fatalf("message[%d]: expected %q, got %q", i, expected, textOf(got[i]))
tp, ok := got[i].Content[0].(fantasy.TextPart)
if !ok || tp.Text != expected {
t.Fatalf("message[%d]: expected %q, got %q", i, expected, tp.Text)
}
}
}
@@ -103,7 +95,7 @@ func TestReplace_swapsHistory(t *testing.T) {
s := NewMessageStore()
s.Add(makeTextMsg("user", "old"))
replacement := []kit.LLMMessage{
replacement := []fantasy.Message{
makeTextMsg("user", "new1"),
makeTextMsg("assistant", "new2"),
}
@@ -113,22 +105,25 @@ func TestReplace_swapsHistory(t *testing.T) {
t.Fatalf("expected 2 messages after replace, got %d", s.Len())
}
got := s.GetAll()
if textOf(got[0]) != "new1" || textOf(got[1]) != "new2" {
t.Fatalf("unexpected messages after replace: %q %q", textOf(got[0]), textOf(got[1]))
tp0, _ := got[0].Content[0].(fantasy.TextPart)
tp1, _ := got[1].Content[0].(fantasy.TextPart)
if tp0.Text != "new1" || tp1.Text != "new2" {
t.Fatalf("unexpected messages after replace: %q %q", tp0.Text, tp1.Text)
}
}
// Replace must deep-copy the incoming slice.
func TestReplace_isolatesInput(t *testing.T) {
s := NewMessageStore()
replacement := []kit.LLMMessage{makeTextMsg("user", "original")}
replacement := []fantasy.Message{makeTextMsg("user", "original")}
s.Replace(replacement)
replacement[0] = makeTextMsg("user", "mutated")
got := s.GetAll()
if textOf(got[0]) != "original" {
t.Fatalf("store was mutated by external slice change after Replace; got %q", textOf(got[0]))
tp, _ := got[0].Content[0].(fantasy.TextPart)
if tp.Text != "original" {
t.Fatalf("store was mutated by external slice change after Replace; got %q", tp.Text)
}
}
@@ -145,8 +140,9 @@ func TestGetAll_returnsCopy(t *testing.T) {
got[0] = makeTextMsg("user", "mutated")
internal := s.GetAll()
if textOf(internal[0]) != "hello" {
t.Fatalf("GetAll returned non-copy; store was mutated to %q", textOf(internal[0]))
tp, _ := internal[0].Content[0].(fantasy.TextPart)
if tp.Text != "hello" {
t.Fatalf("GetAll returned non-copy; store was mutated to %q", tp.Text)
}
}
@@ -183,8 +179,9 @@ func TestClear_allowsSubsequentAdds(t *testing.T) {
t.Fatalf("expected 1 message after Clear+Add, got %d", s.Len())
}
got := s.GetAll()
if textOf(got[0]) != "after" {
t.Fatalf("expected %q, got %q", "after", textOf(got[0]))
tp, _ := got[0].Content[0].(fantasy.TextPart)
if tp.Text != "after" {
t.Fatalf("expected %q, got %q", "after", tp.Text)
}
}
+6 -4
View File
@@ -21,10 +21,8 @@ type UsageUpdater interface {
// the provider does not return exact counts.
EstimateAndUpdateUsage(inputText, outputText string)
// SetContextTokens records the approximate current context window fill
// level. This should be the sum of ALL token categories from the last
// API call: InputTokens + CacheReadTokens + CacheCreationTokens +
// OutputTokens. With Anthropic prompt caching, InputTokens can be
// near-zero while CacheReadTokens holds the bulk of the context.
// level. This should be the final API call's input+output tokens (from
// FinalResponse.Usage), NOT the aggregate TotalUsage.
SetContextTokens(tokens int)
}
@@ -69,6 +67,10 @@ type Options struct {
// Debug enables verbose debug logging.
Debug bool
// CompactMode selects the compact renderer instead of the block renderer for
// message formatting.
CompactMode bool
// UsageTracker is an optional callback for recording token usage after each
// agent step. When non-nil, the app layer calls UpdateUsage (or
// EstimateAndUpdateUsage as a fallback) using the usage data returned by the
+16 -32
View File
@@ -43,30 +43,13 @@ type OpenAICredentials struct {
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.
func oauthTokenExpired(credType string, expiresAt int64) bool {
if credType != "oauth" || expiresAt == 0 {
return false
}
return time.Now().Unix() >= expiresAt
}
// oauthTokenNeedsRefresh reports whether an OAuth token will expire within the
// next 5 minutes, allowing proactive refresh before it becomes invalid.
// Returns false for API key credentials or when no expiry is set.
func oauthTokenNeedsRefresh(credType string, expiresAt int64) bool {
if credType != "oauth" || expiresAt == 0 {
return false
}
return time.Now().Unix() >= (expiresAt - 300) // 5 minutes buffer
}
// IsExpired checks if the OAuth token is expired based on the ExpiresAt timestamp.
// Returns false for API key authentication or if no expiration is set.
func (c *AnthropicCredentials) IsExpired() bool {
return oauthTokenExpired(c.Type, c.ExpiresAt)
if c.Type != "oauth" || c.ExpiresAt == 0 {
return false
}
return time.Now().Unix() >= c.ExpiresAt
}
// NeedsRefresh checks if the OAuth token needs refresh, returning true if the token
@@ -74,13 +57,19 @@ func (c *AnthropicCredentials) IsExpired() bool {
// to avoid authentication failures during operations. Returns false for API key
// authentication or if no expiration is set.
func (c *AnthropicCredentials) NeedsRefresh() bool {
return oauthTokenNeedsRefresh(c.Type, c.ExpiresAt)
if c.Type != "oauth" || c.ExpiresAt == 0 {
return false
}
return time.Now().Unix() >= (c.ExpiresAt - 300) // 5 minutes buffer
}
// IsExpired checks if the OAuth token is expired based on the ExpiresAt timestamp.
// Returns false for API key authentication or if no expiration is set.
func (c *OpenAICredentials) IsExpired() bool {
return oauthTokenExpired(c.Type, c.ExpiresAt)
if c.Type != "oauth" || c.ExpiresAt == 0 {
return false
}
return time.Now().Unix() >= c.ExpiresAt
}
// NeedsRefresh checks if the OAuth token needs refresh, returning true if the token
@@ -88,7 +77,10 @@ func (c *OpenAICredentials) IsExpired() bool {
// to avoid authentication failures during operations. Returns false for API key
// authentication or if no expiration is set.
func (c *OpenAICredentials) NeedsRefresh() bool {
return oauthTokenNeedsRefresh(c.Type, c.ExpiresAt)
if c.Type != "oauth" || c.ExpiresAt == 0 {
return false
}
return time.Now().Unix() >= (c.ExpiresAt - 300) // 5 minutes buffer
}
// CredentialManager handles secure storage and retrieval of authentication credentials.
@@ -471,13 +463,5 @@ 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")
}
+12 -49
View File
@@ -428,10 +428,6 @@ type PreviousCompaction struct {
ModifiedFiles []string
}
// StreamCallback is called for each chunk of text during streaming compaction.
// Return a non-nil error to cancel the stream.
type StreamCallback func(delta string) error
// Compact summarises older messages using the LLM, returning the compaction
// result and a new message slice (summary message + preserved recent
// messages).
@@ -446,8 +442,6 @@ type StreamCallback func(delta string) error
//
// prev carries file tracking from a previous compaction for cumulative
// tracking. Pass nil if there is no prior compaction.
// onChunk is an optional callback for streaming summary text. Pass nil for
// non-streaming compaction.
func Compact(
ctx context.Context,
model fantasy.LanguageModel,
@@ -455,7 +449,6 @@ func Compact(
opts CompactionOptions,
customInstructions string,
prev *PreviousCompaction,
onChunk StreamCallback,
) (*CompactionResult, []fantasy.Message, error) {
opts.defaults()
@@ -494,9 +487,9 @@ func Compact(
var err error
if IsSplitTurn(messages, cutPoint) {
summaryText, err = compactSplitTurn(ctx, model, oldMessages, messages, cutPoint, opts, customInstructions, onChunk)
summaryText, err = compactSplitTurn(ctx, model, oldMessages, messages, cutPoint, opts, customInstructions)
} else {
summaryText, err = compactNormal(ctx, model, oldMessages, opts, customInstructions, onChunk)
summaryText, err = compactNormal(ctx, model, oldMessages, opts, customInstructions)
}
if err != nil {
return nil, nil, err
@@ -534,17 +527,15 @@ func Compact(
}
// compactNormal generates a summary for a clean turn-boundary cut.
// If onChunk is provided, text deltas are streamed to it.
func compactNormal(
ctx context.Context,
model fantasy.LanguageModel,
oldMessages []fantasy.Message,
opts CompactionOptions,
customInstructions string,
onChunk StreamCallback,
) (string, error) {
conversationText := serializeMessages(oldMessages)
return generateSummary(ctx, model, conversationText, opts, customInstructions, onChunk)
return generateSummary(ctx, model, conversationText, opts, customInstructions)
}
// compactSplitTurn handles the case where the cut point lands mid-turn.
@@ -555,7 +546,6 @@ func compactNormal(
//
// The merged result preserves context from both the older history and the
// beginning of the current long turn.
// If onChunk is provided, both summaries and the separator are streamed.
func compactSplitTurn(
ctx context.Context,
model fantasy.LanguageModel,
@@ -564,7 +554,6 @@ func compactSplitTurn(
cutPoint int,
opts CompactionOptions,
customInstructions string,
onChunk StreamCallback,
) (string, error) {
// Find where the split turn starts.
turnStart := findTurnStart(allMessages, cutPoint)
@@ -584,19 +573,12 @@ func compactSplitTurn(
// Generate history summary if there are complete turns before the split.
if len(historyMessages) >= 2 {
historySummary, err = generateSummary(ctx, model,
serializeMessages(historyMessages), opts, "", onChunk)
serializeMessages(historyMessages), opts, "")
if err != nil {
return "", fmt.Errorf("split turn history summary failed: %w", err)
}
}
// Stream the separator between history and turn prefix summaries.
if onChunk != nil && historySummary != "" {
if err := onChunk("\n\n---\n\n## Current Turn (in progress)\n\n"); err != nil {
return "", fmt.Errorf("streaming separator failed: %w", err)
}
}
// Generate turn prefix summary.
turnPrefixText := serializeMessages(turnPrefixMessages)
turnPrefixPrompt := "The messages above are the BEGINNING of a long turn that was split. " +
@@ -606,10 +588,16 @@ func compactSplitTurn(
turnPrefixPrompt += "\n\nAdditional instructions: " + customInstructions
}
turnPrefixSummary, err := generateSummary(ctx, model, turnPrefixText, opts, turnPrefixPrompt, onChunk)
summaryAgent := fantasy.NewAgent(model,
fantasy.WithSystemPrompt(defaultSystemPrompt),
)
result, err := summaryAgent.Generate(ctx, fantasy.AgentCall{
Prompt: turnPrefixText + "\n\n" + turnPrefixPrompt,
})
if err != nil {
return "", fmt.Errorf("split turn prefix summary failed: %w", err)
}
turnPrefixSummary := result.Response.Content.Text()
// Merge the two summaries.
if historySummary != "" && turnPrefixSummary != "" {
@@ -622,14 +610,12 @@ func compactSplitTurn(
}
// generateSummary calls the LLM to produce a structured summary.
// If onChunk is provided, the summary is streamed using Agent.Stream().
func generateSummary(
ctx context.Context,
model fantasy.LanguageModel,
conversationText string,
opts CompactionOptions,
customInstructions string,
onChunk StreamCallback,
) (string, error) {
userPrompt := opts.SummaryPrompt
if userPrompt == "" {
@@ -642,31 +628,8 @@ func generateSummary(
summaryAgent := fantasy.NewAgent(model,
fantasy.WithSystemPrompt(defaultSystemPrompt),
)
prompt := conversationText + "\n\n" + userPrompt
// Use streaming if onChunk is provided.
if onChunk != nil {
var fullText strings.Builder
_, err := summaryAgent.Stream(ctx, fantasy.AgentStreamCall{
Prompt: prompt,
OnTextDelta: func(_, delta string) error {
if delta != "" {
fullText.WriteString(delta)
return onChunk(delta)
}
return nil
},
})
if err != nil {
return "", fmt.Errorf("compaction summarisation (streaming) failed: %w", err)
}
return fullText.String(), nil
}
// Non-streaming path.
result, err := summaryAgent.Generate(ctx, fantasy.AgentCall{
Prompt: prompt,
Prompt: conversationText + "\n\n" + userPrompt,
})
if err != nil {
return "", fmt.Errorf("compaction summarisation failed: %w", err)
+2 -2
View File
@@ -243,7 +243,7 @@ func TestCompact_TooFewMessages(t *testing.T) {
makeTextMessageN(fantasy.MessageRoleUser, 400),
}
result, newMsgs, err := Compact(context.TODO(), nil, msgs, CompactionOptions{}, "", nil, nil)
result, newMsgs, err := Compact(context.TODO(), nil, msgs, CompactionOptions{}, "", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -262,7 +262,7 @@ func TestCompact_WithinBudget(t *testing.T) {
makeTextMessageN(fantasy.MessageRoleAssistant, 400),
}
result, newMsgs, err := Compact(context.TODO(), nil, msgs, CompactionOptions{}, "", nil, nil)
result, newMsgs, err := Compact(context.TODO(), nil, msgs, CompactionOptions{}, "", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
+17 -157
View File
@@ -22,45 +22,6 @@ type MCPServerConfig struct {
AllowedTools []string `json:"allowedTools,omitempty" yaml:"allowedTools,omitempty"`
ExcludedTools []string `json:"excludedTools,omitempty" yaml:"excludedTools,omitempty"`
// OAuth configuration for remote servers that don't support dynamic
// client registration (e.g. GitHub). When OAuthClientID is set, it is
// passed directly to the transport's OAuthConfig instead of relying on
// dynamic registration.
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 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.
// This field is never serialized — it is only used programmatically via the SDK.
InProcessServer any `json:"-" yaml:"-"`
// Legacy fields for backward compatibility
Transport string `json:"transport,omitempty"`
Args []string `json:"args,omitempty"`
@@ -74,18 +35,13 @@ type MCPServerConfig struct {
func (s *MCPServerConfig) UnmarshalJSON(data []byte) error {
// First try to unmarshal as the new format
type newFormat struct {
Type string `json:"type"`
Command []string `json:"command,omitempty"`
Environment map[string]string `json:"environment,omitempty"`
URL string `json:"url,omitempty"`
Headers []string `json:"headers,omitempty"`
AllowedTools []string `json:"allowedTools,omitempty" yaml:"allowedTools,omitempty"`
ExcludedTools []string `json:"excludedTools,omitempty" yaml:"excludedTools,omitempty"`
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"`
Type string `json:"type"`
Command []string `json:"command,omitempty"`
Environment map[string]string `json:"environment,omitempty"`
URL string `json:"url,omitempty"`
Headers []string `json:"headers,omitempty"`
AllowedTools []string `json:"allowedTools,omitempty" yaml:"allowedTools,omitempty"`
ExcludedTools []string `json:"excludedTools,omitempty" yaml:"excludedTools,omitempty"`
}
// Also try legacy format
@@ -98,7 +54,6 @@ 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
@@ -111,11 +66,6 @@ func (s *MCPServerConfig) UnmarshalJSON(data []byte) error {
s.Headers = newConfig.Headers
s.AllowedTools = newConfig.AllowedTools
s.ExcludedTools = newConfig.ExcludedTools
s.OAuthClientID = newConfig.OAuthClientID
s.OAuthClientSecret = newConfig.OAuthClientSecret
s.OAuthScopes = newConfig.OAuthScopes
s.NoOAuth = newConfig.NoOAuth
s.TasksMode = newConfig.TasksMode
return nil
}
@@ -136,7 +86,6 @@ 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
@@ -208,28 +157,11 @@ type Theme struct {
Markdown MarkdownThemeConfig `json:"markdown,omitzero" yaml:"markdown,omitempty"`
}
// GenerationParams defines generation parameter defaults that can be attached
// to individual models. These act as model-level defaults — CLI flags and
// global config values take precedence when explicitly set.
type GenerationParams struct {
MaxTokens *int `json:"maxTokens,omitempty" yaml:"maxTokens,omitempty"`
Temperature *float32 `json:"temperature,omitempty" yaml:"temperature,omitempty"`
TopP *float32 `json:"topP,omitempty" yaml:"topP,omitempty"`
TopK *int32 `json:"topK,omitempty" yaml:"topK,omitempty"`
FrequencyPenalty *float32 `json:"frequencyPenalty,omitempty" yaml:"frequencyPenalty,omitempty"`
PresencePenalty *float32 `json:"presencePenalty,omitempty" yaml:"presencePenalty,omitempty"`
StopSequences []string `json:"stopSequences,omitempty" yaml:"stopSequences,omitempty"`
ThinkingLevel string `json:"thinkingLevel,omitempty" yaml:"thinkingLevel,omitempty"`
SystemPrompt string `json:"systemPrompt,omitempty" yaml:"systemPrompt,omitempty"`
}
// CustomModelConfig defines a custom model that can be used with custom/custom
// or other custom/ prefixed models. These models are loaded from the config file
// and merged into the custom provider in the model registry.
type CustomModelConfig struct {
Name string `json:"name" yaml:"name"`
BaseURL string `json:"baseUrl,omitempty" yaml:"baseUrl,omitempty"`
APIKey string `json:"apiKey,omitempty" yaml:"apiKey,omitempty"`
Family string `json:"family,omitempty" yaml:"family,omitempty"`
Attachment bool `json:"attachment,omitempty" yaml:"attachment,omitempty"`
Reasoning bool `json:"reasoning,omitempty" yaml:"reasoning,omitempty"`
@@ -237,11 +169,6 @@ type CustomModelConfig struct {
Knowledge string `json:"knowledge,omitempty" yaml:"knowledge,omitempty"`
Cost CostConfig `json:"cost" yaml:"cost"`
Limit LimitConfig `json:"limit" yaml:"limit"`
// Generation parameter defaults for this model.
// These are applied when the user hasn't explicitly set the corresponding
// CLI flag or global config value.
Params GenerationParams `json:"params,omitzero" yaml:"params,omitempty"`
}
// CostConfig defines the pricing for a custom model.
@@ -264,19 +191,18 @@ type Config struct {
Model string `json:"model,omitempty" yaml:"model,omitempty"`
MaxSteps int `json:"max-steps,omitempty" yaml:"max-steps,omitempty"`
Debug bool `json:"debug,omitempty" yaml:"debug,omitempty"`
Compact bool `json:"compact,omitempty" yaml:"compact,omitempty"`
SystemPrompt string `json:"system-prompt,omitempty" yaml:"system-prompt,omitempty"`
ProviderAPIKey string `json:"provider-api-key,omitempty" yaml:"provider-api-key,omitempty"`
ProviderURL string `json:"provider-url,omitempty" yaml:"provider-url,omitempty"`
Stream *bool `json:"stream,omitempty" yaml:"stream,omitempty"`
Theme any `json:"theme" yaml:"theme"`
// Model generation parameters
MaxTokens int `json:"max-tokens,omitempty" yaml:"max-tokens,omitempty"`
Temperature *float32 `json:"temperature,omitempty" yaml:"temperature,omitempty"`
TopP *float32 `json:"top-p,omitempty" yaml:"top-p,omitempty"`
TopK *int32 `json:"top-k,omitempty" yaml:"top-k,omitempty"`
FrequencyPenalty *float32 `json:"frequency-penalty,omitempty" yaml:"frequency-penalty,omitempty"`
PresencePenalty *float32 `json:"presence-penalty,omitempty" yaml:"presence-penalty,omitempty"`
StopSequences []string `json:"stop-sequences,omitempty" yaml:"stop-sequences,omitempty"`
MaxTokens int `json:"max-tokens,omitempty" yaml:"max-tokens,omitempty"`
Temperature *float32 `json:"temperature,omitempty" yaml:"temperature,omitempty"`
TopP *float32 `json:"top-p,omitempty" yaml:"top-p,omitempty"`
TopK *int32 `json:"top-k,omitempty" yaml:"top-k,omitempty"`
StopSequences []string `json:"stop-sequences,omitempty" yaml:"stop-sequences,omitempty"`
// Thinking / extended reasoning
ThinkingLevel string `json:"thinking-level,omitempty" yaml:"thinking-level,omitempty"`
@@ -290,12 +216,6 @@ type Config struct {
// Custom model definitions (under custom/ provider)
CustomModels map[string]CustomModelConfig `json:"customModels,omitempty" yaml:"customModels,omitempty"`
// Per-model generation parameter overrides. Keys are "provider/model" strings
// (e.g. "anthropic/claude-sonnet-4-5-20250929", "openai/gpt-4o"). These
// settings act as model-level defaults — CLI flags and global config values
// take precedence when explicitly set.
ModelSettings map[string]GenerationParams `json:"modelSettings,omitempty" yaml:"modelSettings,omitempty"`
}
// GetTransportType returns the transport type for the server config, mapping
@@ -314,18 +234,11 @@ func (s *MCPServerConfig) GetTransportType() string {
return "stdio"
case "remote":
return "streamable"
case "inprocess":
return "inprocess"
default:
return s.Type
}
}
// Programmatic in-process server detection.
if s.InProcessServer != nil {
return "inprocess"
}
// Backward compatibility: infer transport type
if len(s.Command) > 0 {
return "stdio"
@@ -345,17 +258,6 @@ 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":
@@ -367,12 +269,8 @@ func (c *Config) Validate() error {
if serverConfig.URL == "" {
return fmt.Errorf("server %s: url is required for %s transport", serverName, transport)
}
case "inprocess":
if serverConfig.InProcessServer == nil {
return fmt.Errorf("server %s: InProcessServer is required for inprocess transport", serverName)
}
default:
return fmt.Errorf("server %s: unsupported transport type '%s'. Supported types: stdio, sse, streamable, inprocess", serverName, transport)
return fmt.Errorf("server %s: unsupported transport type '%s'. Supported types: stdio, sse, streamable", serverName, transport)
}
}
return nil
@@ -466,55 +364,16 @@ mcpServers:
# debug: false # Enable debug logging
# system-prompt: "/path/to/system-prompt.txt" # System prompt text file
# Model generation parameters (all optional, apply globally to all models)
# Model generation parameters (all optional)
# max-tokens: 4096 # Maximum tokens in response
# temperature: 0.7 # Randomness (0.0-1.0)
# top-p: 0.95 # Nucleus sampling (0.0-1.0)
# top-k: 40 # Top K sampling
# frequency-penalty: 0.0 # Penalize frequent tokens (0.0-2.0)
# presence-penalty: 0.0 # Penalize present tokens (0.0-2.0)
# stop-sequences: ["Human:", "Assistant:"] # Custom stop sequences
# Per-model generation parameter overrides (apply to specific models)
# These act as model-level defaults — CLI flags and global settings above take precedence.
# Keys are "provider/model" strings matching the model you use.
# modelSettings:
# anthropic/claude-sonnet-4-5-20250929:
# temperature: 0.3
# maxTokens: 8192
# openai/gpt-4o:
# temperature: 0.7
# topP: 0.95
# topK: 40
# frequencyPenalty: 0.1
# presencePenalty: 0.1
# anthropic/claude-opus-4-6:
# thinkingLevel: "high"
# maxTokens: 16384
# systemPrompt: "You are a deep reasoning assistant." # or a file path
# 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
# Custom model definitions (under custom/ provider)
# customModels:
# my-local-llama:
# name: "Local Llama 3"
# baseUrl: "http://localhost:8080/v1"
# family: "llama"
# temperature: true
# cost:
# input: 0.0
# output: 0.0
# limit:
# context: 131072
# output: 8192
# params: # Generation parameter defaults for this model
# temperature: 0.8
# topP: 0.95
# topK: 40
# systemPrompt: "You are a helpful local assistant."
`
_, err = file.WriteString(content)
@@ -544,9 +403,10 @@ func FilepathOr[T any](key string, value *T) error {
if err != nil {
return err
}
absPath = filepath.Join(home, absPath[2:])
filepath.Join(home, absPath[2:])
}
if !filepath.IsAbs(absPath) {
// base := GetConfigPath()
base := configPath
if base == "" {
fmt.Fprintf(os.Stderr, "unable to build relative path to config.")
-174
View File
@@ -6,8 +6,6 @@ import (
"path/filepath"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
func TestMCPServerConfig_NewFormat(t *testing.T) {
@@ -544,175 +542,3 @@ func TestEnsureConfigExistsWhenFileExists(t *testing.T) {
t.Error("Existing config file was modified when it shouldn't have been")
}
}
func TestMCPServerConfig_OAuthFields_JSON(t *testing.T) {
jsonData := `{
"type": "remote",
"url": "https://api.githubcopilot.com/mcp/",
"oauthClientId": "Ov23liXXXXXXXXXXXXXX",
"oauthClientSecret": "secret123",
"oauthScopes": ["read:user", "repo"]
}`
var cfg MCPServerConfig
err := json.Unmarshal([]byte(jsonData), &cfg)
if err != nil {
t.Fatalf("Failed to unmarshal: %v", err)
}
if cfg.Type != "remote" {
t.Errorf("Expected type 'remote', got %q", cfg.Type)
}
if cfg.URL != "https://api.githubcopilot.com/mcp/" {
t.Errorf("Expected URL, got %q", cfg.URL)
}
if cfg.OAuthClientID != "Ov23liXXXXXXXXXXXXXX" {
t.Errorf("Expected OAuthClientID 'Ov23liXXXXXXXXXXXXXX', got %q", cfg.OAuthClientID)
}
if cfg.OAuthClientSecret != "secret123" {
t.Errorf("Expected OAuthClientSecret 'secret123', got %q", cfg.OAuthClientSecret)
}
if len(cfg.OAuthScopes) != 2 || cfg.OAuthScopes[0] != "read:user" || cfg.OAuthScopes[1] != "repo" {
t.Errorf("Expected OAuthScopes [read:user, repo], got %v", cfg.OAuthScopes)
}
}
func TestMCPServerConfig_OAuthFields_YAML(t *testing.T) {
yamlData := `
type: remote
url: https://api.githubcopilot.com/mcp/
oauthClientId: "Ov23liXXXXXXXXXXXXXX"
oauthScopes:
- read:user
- repo
`
var cfg MCPServerConfig
err := yaml.Unmarshal([]byte(yamlData), &cfg)
if err != nil {
t.Fatalf("Failed to unmarshal YAML: %v", err)
}
if cfg.Type != "remote" {
t.Errorf("Expected type 'remote', got %q", cfg.Type)
}
if cfg.OAuthClientID != "Ov23liXXXXXXXXXXXXXX" {
t.Errorf("Expected OAuthClientID 'Ov23liXXXXXXXXXXXXXX', got %q", cfg.OAuthClientID)
}
if len(cfg.OAuthScopes) != 2 || cfg.OAuthScopes[0] != "read:user" || cfg.OAuthScopes[1] != "repo" {
t.Errorf("Expected OAuthScopes [read:user, repo], got %v", cfg.OAuthScopes)
}
}
func TestMCPServerConfig_OAuthFields_Omitted(t *testing.T) {
// Verify that omitting OAuth fields still works (backward compat).
jsonData := `{
"type": "remote",
"url": "https://example.com/mcp"
}`
var cfg MCPServerConfig
err := json.Unmarshal([]byte(jsonData), &cfg)
if err != nil {
t.Fatalf("Failed to unmarshal: %v", err)
}
if cfg.OAuthClientID != "" {
t.Errorf("Expected empty OAuthClientID, got %q", cfg.OAuthClientID)
}
if cfg.OAuthClientSecret != "" {
t.Errorf("Expected empty OAuthClientSecret, got %q", cfg.OAuthClientSecret)
}
if len(cfg.OAuthScopes) != 0 {
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)
}
})
}
+24 -181
View File
@@ -7,7 +7,6 @@ import (
"io"
"os"
"os/exec"
"regexp"
"strings"
"sync"
"time"
@@ -19,18 +18,10 @@ import (
// It receives tool call ID, tool name, output chunk, and whether it's stderr.
type ToolOutputCallback func(toolCallID, toolName, chunk string, isStderr bool)
// PasswordPromptCallback is the signature for password prompts.
// It receives a prompt message and returns the password and whether it was cancelled.
type PasswordPromptCallback func(prompt string) (password string, cancelled bool)
// contextKey is a custom type for context keys to avoid collisions.
type contextKey string
const (
toolOutputCallbackKey contextKey = "toolOutputCallback"
sudoPasswordKey contextKey = "sudoPassword"
passwordPromptKey contextKey = "passwordPrompt"
)
const toolOutputCallbackKey contextKey = "toolOutputCallback"
// ContextWithToolOutputCallback returns a new context with the tool output callback set.
func ContextWithToolOutputCallback(ctx context.Context, callback ToolOutputCallback) context.Context {
@@ -45,39 +36,23 @@ func toolOutputCallbackFromContext(ctx context.Context) ToolOutputCallback {
return nil
}
// ContextWithPasswordPrompt returns a new context with the password prompt callback set.
// This allows the TUI to show a modal password prompt when sudo needs a password.
func ContextWithPasswordPrompt(ctx context.Context, callback PasswordPromptCallback) context.Context {
return context.WithValue(ctx, passwordPromptKey, callback)
}
// passwordPromptFromContext retrieves the password prompt callback from context.
func passwordPromptFromContext(ctx context.Context) PasswordPromptCallback {
if cb, ok := ctx.Value(passwordPromptKey).(PasswordPromptCallback); ok {
return cb
}
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 {
return pw
}
return ""
}
const defaultBashTimeout = 120 * time.Second
const maxBashTimeout = 600 * time.Second
// bannedCmdRe matches bash builtin commands that are not allowed for security reasons.
var bannedCmdRe = regexp.MustCompile(`^(alias|bg|bind|builtin|caller|command|compgen|complete|compopt|coproc|dirs|disown|enable|fc|fg|hash|help|history|jobs|kill|logout|mapfile|popd|pushd|readonly|select|set|shopt|source|suspend|times|trap|type|typeset|ulimit|umask|unalias|wait)\s`)
var bannedCommands = []string{
"alias ", "bg ", "bind ", "builtin ",
"caller ", "command ", "compgen ",
"complete ", "compopt ", "coproc ",
"dirs ", "disown ", "enable ",
"fc ", "fg ", "hash ", "help ",
"history ", "jobs ", "kill ",
"logout ", "mapfile ", "popd ",
"pushd ", "readonly ", "select ",
"set ", "shopt ", "source ",
"suspend ", "times ", "trap ",
"type ", "typeset ", "ulimit ",
"umask ", "unalias ", "wait ",
}
type bashArgs struct {
Command string `json:"command"`
@@ -109,66 +84,6 @@ func NewBashTool(opts ...ToolOption) fantasy.AgentTool {
}
}
// sudoCommandRe matches sudo commands that need to be rewritten for -S mode.
// It matches "sudo" as a word boundary, optionally preceded by environment variables.
var sudoCommandRe = regexp.MustCompile(`(?i)(^|[&|;|]|\|\||&&)\s*(\w+=\S+\s+)?\bsudo\b`)
// truncateCommand truncates a long command for display.
func truncateCommand(cmd string, maxLen int) string {
if len(cmd) <= maxLen {
return cmd
}
return cmd[:maxLen-3] + "..."
}
// rewriteSudoForStdin rewrites sudo commands to use -S -p ” for stdin password input.
// It transforms: sudo cmd → sudo -S -p ” cmd
func rewriteSudoForStdin(command string) string {
// Find all matches and their positions
matches := sudoCommandRe.FindAllStringIndex(command, -1)
if matches == nil {
return command
}
// Build result from end to start to preserve indices
result := command
for i := len(matches) - 1; i >= 0; i-- {
match := matches[i]
start, end := match[0], match[1]
matchedText := result[start:end]
// Extract just the "sudo" part (after any prefix)
sudoIdx := strings.Index(strings.ToLower(matchedText), "sudo")
if sudoIdx == -1 {
continue
}
prefix := matchedText[:sudoIdx]
sudoPart := matchedText[sudoIdx:]
// Check if the text immediately after "sudo" in the result contains -S
afterSudo := result[end:]
if strings.HasPrefix(strings.TrimLeft(afterSudo, " \t"), "-S") {
// Already has -S flag, skip
continue
}
// Insert -S -p '' after "sudo"
newSudo := strings.Replace(sudoPart, "sudo", "sudo -S -p ''", 1)
result = result[:start] + prefix + newSudo + result[end:]
}
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 {
@@ -179,8 +94,10 @@ func executeBash(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
}
// Check for banned commands
if bannedCmdRe.MatchString(args.Command) {
return fantasy.NewTextErrorResponse(fmt.Sprintf("command '%s' is not allowed", args.Command)), nil
for _, banned := range bannedCommands {
if strings.HasPrefix(args.Command, banned) {
return fantasy.NewTextErrorResponse(fmt.Sprintf("command '%s' is not allowed", args.Command)), nil
}
}
// Determine timeout
@@ -193,47 +110,7 @@ func executeBash(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
cmdCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
// Check for sudo password in context or environment
sudoPassword := sudoPasswordFromContext(ctx)
if sudoPassword == "" {
sudoPassword = os.Getenv("SUDO_PASSWORD")
}
command := args.Command
// If command contains sudo and we don't have a password, check if sudo needs one
if sudoPassword == "" && sudoCommandRe.MatchString(command) {
// Check if sudo credentials are cached using sudo -n (non-interactive)
testCmd := exec.CommandContext(cmdCtx, "sudo", "-n", "true")
testCmd.Dir = workDir
if err := testCmd.Run(); err != nil {
// Sudo needs a password - try to prompt via callback
if promptCallback := passwordPromptFromContext(ctx); promptCallback != nil {
pw, cancelled := promptCallback("Sudo password required for: " + truncateCommand(args.Command, 60))
if cancelled {
return fantasy.NewTextErrorResponse("sudo password prompt cancelled"), nil
}
if pw == "" {
return fantasy.NewTextErrorResponse("no sudo password provided"), nil
}
sudoPassword = pw
command = rewriteSudoForStdin(command)
} else {
// No callback available - return error with helpful message
return fantasy.NewTextErrorResponse(
"This command requires sudo access. " +
"Please run 'sudo -v' in your terminal first to cache credentials, " +
"or set the SUDO_PASSWORD environment variable."), nil
}
}
// Credentials are cached or password was provided, proceed
}
// If we have a sudo password, rewrite the command to use sudo -S
if sudoPassword != "" && sudoCommandRe.MatchString(command) {
command = rewriteSudoForStdin(command)
}
cmd := exec.CommandContext(cmdCtx, "bash", "-c", command)
cmd := exec.CommandContext(cmdCtx, "bash", "-c", args.Command)
if workDir != "" {
cmd.Dir = workDir
}
@@ -251,18 +128,18 @@ func executeBash(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
if outputCallback != nil {
// Streaming mode: use pipes to capture output as it arrives
return executeBashStreaming(cmdCtx, call, cmd, outputCallback, sudoPassword)
return executeBashStreaming(cmdCtx, call, cmd, outputCallback)
}
// Non-streaming mode: collect all output at once (original behavior)
return executeBashBuffered(cmdCtx, call, cmd, sudoPassword)
return executeBashBuffered(cmdCtx, call, cmd)
}
// 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) {
func executeBashBuffered(cmdCtx context.Context, call fantasy.ToolCall, cmd *exec.Cmd) (fantasy.ToolResponse, error) {
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
return fantasy.NewTextErrorResponse("failed to create stdout pipe"), nil
@@ -272,27 +149,10 @@ func executeBashBuffered(cmdCtx context.Context, call fantasy.ToolCall, cmd *exe
return fantasy.NewTextErrorResponse("failed to create stderr pipe"), nil
}
// 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
}
}
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")
}()
}
// Read pipes concurrently
var wg sync.WaitGroup
var stdout, stderr strings.Builder
@@ -334,7 +194,7 @@ 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) {
func executeBashStreaming(cmdCtx context.Context, call fantasy.ToolCall, cmd *exec.Cmd, outputCallback ToolOutputCallback) (fantasy.ToolResponse, error) {
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
return fantasy.NewTextErrorResponse("failed to create stdout pipe"), nil
@@ -344,28 +204,11 @@ func executeBashStreaming(cmdCtx context.Context, call fantasy.ToolCall, cmd *ex
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")
}()
}
// Stream stdout and stderr concurrently
var wg sync.WaitGroup
var mu sync.Mutex
-69
View File
@@ -127,72 +127,3 @@ func TestBash_EmptyCommand(t *testing.T) {
t.Fatal("expected error for empty command")
}
}
func TestRewriteSudoForStdin(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "simple sudo",
input: "sudo apt update",
expected: "sudo -S -p '' apt update",
},
{
name: "sudo with env var",
input: "DEBIAN_FRONTEND=noninteractive sudo apt update",
expected: "DEBIAN_FRONTEND=noninteractive sudo -S -p '' apt update",
},
{
name: "sudo in pipeline",
input: "echo test | sudo tee /etc/test.conf",
expected: "echo test | sudo -S -p '' tee /etc/test.conf",
},
{
name: "sudo after &&",
input: "apt update && sudo apt upgrade",
expected: "apt update && sudo -S -p '' apt upgrade",
},
{
name: "already has -S flag",
input: "sudo -S apt update",
expected: "sudo -S apt update",
},
{
name: "no sudo",
input: "apt update && apt upgrade",
expected: "apt update && apt upgrade",
},
{
name: "sudo in string (should not match)",
input: "echo 'use sudo carefully'",
expected: "echo 'use sudo carefully'",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := rewriteSudoForStdin(tt.input)
if result != tt.expected {
t.Errorf("rewriteSudoForStdin(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
func TestSudoPasswordFromContext(t *testing.T) {
// Test with password in context
ctx := ContextWithSudoPassword(context.Background(), "secret123")
pw := sudoPasswordFromContext(ctx)
if pw != "secret123" {
t.Errorf("expected password 'secret123', got %q", pw)
}
// Test without password
ctx = context.Background()
pw = sudoPasswordFromContext(ctx)
if pw != "" {
t.Errorf("expected empty password, got %q", pw)
}
}
+42 -6
View File
@@ -21,9 +21,12 @@ 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"`
Edits []Edit `json:"edits"`
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
}
// replacement represents a normalized edit ready for processing.
@@ -49,12 +52,20 @@ func NewEditTool(opts ...ToolOption) fantasy.AgentTool {
return &coreTool{
info: fantasy.ToolInfo{
Name: "edit",
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.",
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.",
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.",
@@ -74,7 +85,7 @@ func NewEditTool(opts ...ToolOption) fantasy.AgentTool {
},
},
},
Required: []string{"path", "edits"},
Required: []string{"path"},
},
handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
return executeEdit(ctx, call, cfg.WorkDir)
@@ -152,11 +163,36 @@ 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) {
if len(args.Edits) == 0 {
return nil, fmt.Errorf("edits array is required and must not be empty")
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 !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 == "" {
+44 -62
View File
@@ -389,11 +389,9 @@ func TestExecuteEdit_ExactMatch(t *testing.T) {
writeFileOrFail(t, path, original)
input, _ := json.Marshal(editArgs{
Path: path,
Edits: []Edit{{
OldText: "fmt.Println(\"hello\")",
NewText: "fmt.Println(\"world\")",
}},
Path: path,
OldText: "fmt.Println(\"hello\")",
NewText: "fmt.Println(\"world\")",
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
@@ -428,11 +426,9 @@ func TestExecuteEdit_ExactMatch_DoesNotCorruptRest(t *testing.T) {
target := lines[49]
replacement := "REPLACED_LINE_50"
input, _ := json.Marshal(editArgs{
Path: path,
Edits: []Edit{{
OldText: target,
NewText: replacement,
}},
Path: path,
OldText: target,
NewText: replacement,
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
@@ -474,11 +470,9 @@ func TestExecuteEdit_FuzzyMatch_TrailingWhitespace(t *testing.T) {
// Search without trailing whitespace (common LLM behavior)
input, _ := json.Marshal(editArgs{
Path: path,
Edits: []Edit{{
OldText: "func foo() {\n\treturn 1\n}",
NewText: "func foo() {\n\treturn 2\n}",
}},
Path: path,
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)
@@ -525,11 +519,9 @@ 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,
Edits: []Edit{{
OldText: search,
NewText: "REPLACED",
}},
Path: path,
OldText: search,
NewText: "REPLACED",
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
@@ -554,11 +546,9 @@ func TestExecuteEdit_MultipleMatches_Fails(t *testing.T) {
writeFileOrFail(t, path, "hello\nworld\nhello\n")
input, _ := json.Marshal(editArgs{
Path: path,
Edits: []Edit{{
OldText: "hello",
NewText: "goodbye",
}},
Path: path,
OldText: "hello",
NewText: "goodbye",
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
@@ -585,11 +575,9 @@ func TestExecuteEdit_NoMatch_Fails(t *testing.T) {
writeFileOrFail(t, path, "hello world\n")
input, _ := json.Marshal(editArgs{
Path: path,
Edits: []Edit{{
OldText: "nonexistent text",
NewText: "replacement",
}},
Path: path,
OldText: "nonexistent text",
NewText: "replacement",
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
@@ -613,11 +601,9 @@ func TestExecuteEdit_CRLFNormalization(t *testing.T) {
writeFileOrFail(t, path, "line1\r\nline2\r\nline3\r\n")
input, _ := json.Marshal(editArgs{
Path: path,
Edits: []Edit{{
OldText: "line2",
NewText: "LINE2",
}},
Path: path,
OldText: "line2",
NewText: "LINE2",
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
@@ -636,10 +622,8 @@ func TestExecuteEdit_CRLFNormalization(t *testing.T) {
func TestExecuteEdit_MissingPath(t *testing.T) {
input, _ := json.Marshal(editArgs{
Edits: []Edit{{
OldText: "x",
NewText: "y",
}},
OldText: "x",
NewText: "y",
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, "")
if err != nil {
@@ -652,11 +636,9 @@ func TestExecuteEdit_MissingPath(t *testing.T) {
func TestExecuteEdit_NonexistentFile(t *testing.T) {
input, _ := json.Marshal(editArgs{
Path: "/tmp/nonexistent_edit_test_file_12345.go",
Edits: []Edit{{
OldText: "x",
NewText: "y",
}},
Path: "/tmp/nonexistent_edit_test_file_12345.go",
OldText: "x",
NewText: "y",
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, "")
if err != nil {
@@ -679,11 +661,9 @@ func TestExecuteEdit_DiffContainsHunkHeader(t *testing.T) {
writeFileOrFail(t, path, strings.Join(lines, "\n")+"\n")
input, _ := json.Marshal(editArgs{
Path: path,
Edits: []Edit{{
OldText: "line_10_content",
NewText: "REPLACED",
}},
Path: path,
OldText: "line_10_content",
NewText: "REPLACED",
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
@@ -704,11 +684,9 @@ func TestExecuteEdit_MetadataContainsFileDiffs(t *testing.T) {
writeFileOrFail(t, path, "old content\n")
input, _ := json.Marshal(editArgs{
Path: path,
Edits: []Edit{{
OldText: "old content",
NewText: "new content",
}},
Path: path,
OldText: "old content",
NewText: "new content",
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
@@ -927,14 +905,18 @@ func TestExecuteEdit_MultiEdit_EmptyArray(t *testing.T) {
}
}
func TestExecuteEdit_EmptyEditsArray_Fails(t *testing.T) {
func TestExecuteEdit_MultiEdit_MixedWithSingleMode(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "empty.txt")
path := filepath.Join(dir, "mixed.txt")
writeFileOrFail(t, path, "hello\n")
input, _ := json.Marshal(editArgs{
Path: path,
Edits: []Edit{},
input, _ := json.Marshal(map[string]any{
"path": path,
"old_text": "hello",
"new_text": "HELLO",
"edits": []Edit{
{OldText: "hello", NewText: "HI"},
},
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
@@ -942,10 +924,10 @@ func TestExecuteEdit_EmptyEditsArray_Fails(t *testing.T) {
t.Fatalf("executeEdit error: %v", err)
}
if !resp.IsError {
t.Error("expected error for empty edits array")
t.Error("expected error when mixing single and multi-edit modes")
}
if !strings.Contains(resp.Content, "required") {
t.Errorf("expected 'required' in error, got: %s", resp.Content)
if !strings.Contains(resp.Content, "cannot use") {
t.Errorf("expected 'cannot use' in error, got: %s", resp.Content)
}
}
+20 -1
View File
@@ -67,7 +67,7 @@ func executeRead(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
}
if info.IsDir() {
return fantasy.NewTextErrorResponse(fmt.Sprintf("'%s' is a directory, not a file. Use the ls tool to list directory contents.", args.Path)), nil
return readDirectory(absPath)
}
content, err := os.ReadFile(absPath)
@@ -116,6 +116,25 @@ func executeRead(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
return fantasy.NewTextResponse(tr.Content), nil
}
func readDirectory(absPath string) (fantasy.ToolResponse, error) {
entries, err := os.ReadDir(absPath)
if err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to read directory: %v", err)), nil
}
var result strings.Builder
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() {
name += "/"
}
result.WriteString(name + "\n")
}
tr := truncateHead(result.String(), 500, defaultMaxBytes)
return fantasy.NewTextResponse(tr.Content), nil
}
// resolvePathWithWorkDir resolves a path to an absolute path relative to the
// given workDir. If workDir is empty, os.Getwd() is used.
func resolvePathWithWorkDir(path, workDir string) (string, error) {
+40 -33
View File
@@ -28,14 +28,14 @@ type SubagentSpawnResult struct {
// SubagentSpawnFunc is a callback that spawns an in-process subagent. The
// parent Kit instance injects this into the context so the core tool can
// call back without importing pkg/kit (which would create a cycle).
// The toolCallID parameter is the LLM-assigned ID of the subagent
// The toolCallID parameter is the LLM-assigned ID of the spawn_subagent
// tool call, enabling the parent to correlate subagent events.
type SubagentSpawnFunc func(ctx context.Context, toolCallID, prompt, model, systemPrompt string, timeout time.Duration) (*SubagentSpawnResult, error)
type subagentCtxKey struct{}
// WithSubagentSpawner stores a spawn function in the context so that the
// subagent core tool can create in-process subagents.
// spawn_subagent core tool can create in-process subagents.
func WithSubagentSpawner(ctx context.Context, fn SubagentSpawnFunc) context.Context {
return context.WithValue(ctx, subagentCtxKey{}, fn)
}
@@ -49,7 +49,7 @@ func getSubagentSpawner(ctx context.Context) SubagentSpawnFunc {
}
// ---------------------------------------------------------------------------
// subagent tool
// spawn_subagent tool
// ---------------------------------------------------------------------------
type subagentArgs struct {
@@ -59,11 +59,11 @@ type subagentArgs struct {
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
}
// NewSubagentTool creates the subagent core tool.
// NewSubagentTool creates the spawn_subagent core tool.
func NewSubagentTool(opts ...ToolOption) fantasy.AgentTool {
return &coreTool{
info: fantasy.ToolInfo{
Name: "subagent",
Name: "spawn_subagent",
Description: `Spawn a subagent to perform a task autonomously.
The subagent runs as a separate in-process Kit instance with full tool access
@@ -86,7 +86,7 @@ Example use cases:
},
"model": map[string]any{
"type": "string",
"description": "Optional model override. Empty string uses the current model.",
"description": "Optional model override (e.g. 'anthropic/claude-haiku-3-5-20241022' for faster/cheaper tasks)",
},
"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, minimum recommended: 240)",
"description": "Maximum execution time in seconds (default: 300, max: 1800)",
},
},
Required: []string{"task"},
@@ -130,22 +130,13 @@ func executeSubagent(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolRe
), fmt.Errorf("no subagent spawner in context")
}
// Build a clean context for the subagent that inherits values (e.g. the
// spawner callback) but is completely detached from the parent's
// deadline AND cancellation. The subagent gets its own independent
// timeout (applied downstream in Kit.Subagent).
//
// Why full detachment instead of propagating parent cancellation?
// The parent context may already be done (deadline exceeded or
// cancelled) by the time this tool handler executes — for example when
// the generation loop context carries a deadline, when the user
// double-ESC cancels mid-turn, or when parallel tool execution
// encounters a race between stream completion and tool dispatch. Using
// context.WithoutCancel (Go 1.21+) ensures the subagent always starts
// cleanly with a fresh timeout, following the pattern used by crush for
// shutdown-resilient child work. The subagent's own timeout
// (defaultSubagentTimeout / user-specified) provides the safety net.
spawnCtx := context.WithoutCancel(valuesContext{parent: ctx})
// Detach from the parent's deadline so the subagent gets its own
// independent timeout (applied downstream in Kit.Subagent). The parent
// context may carry a tight deadline from the LLM generation loop or
// other tool timeouts that would prematurely kill the subagent.
// We preserve context values (spawner, etc.) and propagate parent
// cancellation (e.g. user hits Ctrl-C) without inheriting the deadline.
spawnCtx := detachedWithCancel(ctx)
// Spawn in-process subagent.
result, err := spawner(spawnCtx, call.ID, args.Task, args.Model, args.SystemPrompt, timeout)
@@ -182,21 +173,37 @@ func executeSubagent(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolRe
}
// ---------------------------------------------------------------------------
// Context helpers
// Context detachment
// ---------------------------------------------------------------------------
// valuesContext preserves a parent context's values (e.g. the subagent
// spawner callback) while stripping its deadline and cancellation. Combined
// with context.WithoutCancel() this gives the subagent a completely clean
// context that only inherits value-based dependencies.
type valuesContext struct {
// detachedContext wraps a parent context, preserving its values but removing
// its deadline and cancellation. This allows the subagent to have its own
// independent timeout while still accessing context-stored values (e.g. the
// subagent spawner function).
type detachedContext struct {
parent context.Context
}
func (v valuesContext) Deadline() (time.Time, bool) { return time.Time{}, false }
func (v valuesContext) Done() <-chan struct{} { return nil }
func (v valuesContext) Err() error { return nil }
func (v valuesContext) Value(key any) any { return v.parent.Value(key) }
func (d detachedContext) Deadline() (time.Time, bool) { return time.Time{}, false }
func (d detachedContext) Done() <-chan struct{} { return nil }
func (d detachedContext) Err() error { return nil }
func (d detachedContext) Value(key any) any { return d.parent.Value(key) }
// detachedWithCancel creates a new context that inherits values from the
// parent but has no deadline. Cancellation of the parent is propagated: when
// the parent is cancelled the returned context is also cancelled, but the
// parent's deadline does not apply to the child.
func detachedWithCancel(parent context.Context) context.Context {
child, cancel := context.WithCancel(detachedContext{parent: parent})
go func() {
select {
case <-parent.Done():
cancel()
case <-child.Done():
}
}()
return child
}
// truncateResponse limits the response length to avoid overwhelming context windows.
func truncateResponse(s string, maxLen int) string {
-115
View File
@@ -1,115 +0,0 @@
package core
import (
"context"
"testing"
"time"
)
func TestValuesContext_StripsDeadlineAndCancellation(t *testing.T) {
// Parent with a tight deadline.
parent, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
defer cancel()
time.Sleep(5 * time.Millisecond) // Let deadline expire.
if parent.Err() == nil {
t.Fatal("expected parent to be expired")
}
vc := valuesContext{parent: parent}
if _, ok := vc.Deadline(); ok {
t.Error("valuesContext should report no deadline")
}
if vc.Done() != nil {
t.Error("valuesContext.Done() should return nil")
}
if vc.Err() != nil {
t.Errorf("valuesContext.Err() should be nil, got %v", vc.Err())
}
}
func TestValuesContext_PreservesValues(t *testing.T) {
type testKey struct{}
parent := context.WithValue(context.Background(), testKey{}, "hello")
vc := valuesContext{parent: parent}
got, ok := vc.Value(testKey{}).(string)
if !ok || got != "hello" {
t.Errorf("expected value 'hello', got %q (ok=%v)", got, ok)
}
}
func TestSpawnContext_SurvivesCancelledParent(t *testing.T) {
// Simulate the exact scenario from the bug: the parent generation
// context is already cancelled when the subagent tool handler runs.
parent, cancel := context.WithCancel(context.Background())
cancel() // Cancelled before detach.
// This is what executeSubagent now does:
spawnCtx := context.WithoutCancel(valuesContext{parent: parent})
// The spawn context must be alive.
if spawnCtx.Err() != nil {
t.Fatalf("spawnCtx should be alive, got err: %v", spawnCtx.Err())
}
// Adding a timeout should produce a working context.
tCtx, tCancel := context.WithTimeout(spawnCtx, 5*time.Second)
defer tCancel()
if tCtx.Err() != nil {
t.Fatalf("timeout context should be alive, got err: %v", tCtx.Err())
}
}
func TestSpawnContext_SurvivesDeadlineExceededParent(t *testing.T) {
// Simulate: parent had a deadline that already expired.
parent, pCancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
defer pCancel()
time.Sleep(5 * time.Millisecond)
if parent.Err() != context.DeadlineExceeded {
t.Fatalf("expected parent deadline exceeded, got: %v", parent.Err())
}
spawnCtx := context.WithoutCancel(valuesContext{parent: parent})
if spawnCtx.Err() != nil {
t.Fatalf("spawnCtx should be alive after deadline-exceeded parent, got: %v", spawnCtx.Err())
}
}
func TestSpawnContext_PreservesSpawnerValue(t *testing.T) {
// Verify the subagent spawner callback survives context detachment.
called := false
spawner := SubagentSpawnFunc(func(ctx context.Context, toolCallID, prompt, model, systemPrompt string, timeout time.Duration) (*SubagentSpawnResult, error) {
called = true
return &SubagentSpawnResult{Response: "ok"}, nil
})
parent := WithSubagentSpawner(context.Background(), spawner)
// Cancel the parent.
parentCtx, cancel := context.WithCancel(parent)
cancel()
spawnCtx := context.WithoutCancel(valuesContext{parent: parentCtx})
// Should be able to retrieve the spawner from the detached context.
recovered := getSubagentSpawner(spawnCtx)
if recovered == nil {
t.Fatal("spawner should be recoverable from detached context")
}
result, err := recovered(spawnCtx, "tc1", "test task", "", "", time.Minute)
if err != nil {
t.Fatalf("spawner call failed: %v", err)
}
if !called {
t.Error("spawner was not called")
}
if result.Response != "ok" {
t.Errorf("expected 'ok', got %q", result.Response)
}
}
+1 -1
View File
@@ -86,7 +86,7 @@ func ReadOnlyTools(opts ...ToolOption) []fantasy.AgentTool {
}
}
// SubagentTools returns all core tools except subagent. This prevents
// SubagentTools returns all core tools except spawn_subagent. This prevents
// infinite recursion when a subagent is itself a Kit instance.
func SubagentTools(opts ...ToolOption) []fantasy.AgentTool {
return []fantasy.AgentTool{
-97
View File
@@ -1,97 +0,0 @@
// 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,
}
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
}
+6 -545
View File
@@ -77,64 +77,6 @@ type Context struct {
// ctx.CancelAndSend("Stop what you're doing and focus on the tests")
CancelAndSend func(string)
// Abort cancels the current agent turn (if running) and clears the
// message queue. Unlike CancelAndSend, no new message is injected —
// the agent simply stops. Safe to call when idle (no-op).
//
// Example:
//
// ctx.Abort() // stop whatever the agent is doing
Abort func()
// IsIdle returns true when the agent is not processing a turn.
// Extensions can use this to decide whether to dispatch immediately
// or queue work for later.
//
// Example:
//
// if ctx.IsIdle() {
// ctx.SendMessage("start new task")
// }
IsIdle func() bool
// Compact triggers context compaction, summarising older messages to
// free context window space. Returns an error if compaction cannot
// start (e.g. agent is busy or app is closed). The actual compaction
// runs asynchronously; use OnComplete/OnError callbacks in
// CompactConfig to observe the result.
//
// Example:
//
// err := ctx.Compact(ext.CompactConfig{
// OnComplete: func() { ctx.PrintInfo("Compaction done") },
// OnError: func(errMsg string) { ctx.PrintError("Compact failed: " + errMsg) },
// })
Compact func(CompactConfig) error
// SendMultimodalMessage injects a message with file attachments (images,
// documents) into the conversation and triggers a new agent turn. Files
// are described by FilePart structs containing the raw bytes, filename,
// and MIME type. If the agent is busy the message is queued.
//
// Example:
//
// data, _ := os.ReadFile("photo.jpg")
// ctx.SendMultimodalMessage("Describe this image", []ext.FilePart{
// {Filename: "photo.jpg", Data: data, MediaType: "image/jpeg"},
// })
SendMultimodalMessage func(text string, files []FilePart)
// GetSessionUsage returns aggregated token usage and cost statistics
// for the current session. This includes total input/output tokens,
// cache read/write tokens, total cost, and request count.
//
// Example:
//
// usage := ctx.GetSessionUsage()
// fmt.Sprintf("Tokens: ↑%d ↓%d Cost: $%.3f",
// usage.TotalInputTokens, usage.TotalOutputTokens, usage.TotalCost)
GetSessionUsage func() SessionUsage
// SetWidget places or updates a persistent widget in the TUI. Widgets
// remain visible across agent turns until explicitly removed. The
// widget is identified by WidgetConfig.ID; calling SetWidget with the
@@ -630,102 +572,6 @@ type Context struct {
// })
// // handle.Kill() to cancel, handle.Wait() to block
SpawnSubagent func(SubagentConfig) (*SubagentHandle, *SubagentResult, error)
// -------------------------------------------------------------------------
// Tree Navigation API (Phase 1 Bridge)
// -------------------------------------------------------------------------
// GetTreeNode returns a node by ID with full metadata and children.
// Returns nil if entry not found.
GetTreeNode func(entryID string) *TreeNode
// GetCurrentBranch returns the path from root to current leaf.
// Each node contains full metadata (unlike GetMessages which flattens).
GetCurrentBranch func() []TreeNode
// GetChildren returns direct child IDs of an entry.
GetChildren func(entryID string) []string
// NavigateTo branches/forks the session to the specified entry ID.
// Equivalent to SDK's Branch() but for extensions.
NavigateTo func(entryID string) TreeNavigationResult
// SummarizeBranch uses LLM to summarize a branch range.
// Returns summary text or error string (empty if success).
SummarizeBranch func(fromID, toID string) string
// CollapseBranch replaces a branch range with a summary entry.
// This is the "fresh context" primitive for context window management.
CollapseBranch func(fromID, toID, summary string) TreeNavigationResult
// -------------------------------------------------------------------------
// Skill Loading API (Phase 2 Bridge)
// -------------------------------------------------------------------------
// LoadSkill loads a single skill file from path.
// Parses YAML frontmatter, returns skill with content ready for injection.
LoadSkill func(path string) (*Skill, string)
// LoadSkillsFromDir discovers and loads all skills from a directory.
LoadSkillsFromDir func(dir string) SkillLoadResult
// DiscoverSkills finds skills in standard locations.
// Checks ~/.config/kit/skills/, .kit/skills/, .agents/skills/
DiscoverSkills func() SkillLoadResult
// InjectSkillAsContext sends a skill's content as a system message.
// Looks up skill by name from discovered skills.
InjectSkillAsContext func(skillName string) string
// InjectRawSkillAsContext loads and immediately injects a skill file.
InjectRawSkillAsContext func(path string) string
// GetAvailableSkills returns all currently loaded/discovered skills.
GetAvailableSkills func() []Skill
// -------------------------------------------------------------------------
// Template Parsing API (Phase 3 Bridge)
// -------------------------------------------------------------------------
// ParseTemplate extracts {{variables}} from template content.
ParseTemplate func(name, content string) PromptTemplate
// RenderTemplate substitutes variables into template content.
RenderTemplate func(tpl PromptTemplate, vars map[string]string) string
// ParseArguments parses command-line style arguments.
ParseArguments func(input string, pattern ArgumentPattern) ParseResult
// SimpleParseArguments parses $1, $2, $@ style arguments.
// Returns slice where [0]=full input, [1]=$1, [2]=$2, ... [n]=$@
SimpleParseArguments func(input string, count int) []string
// EvaluateModelConditional checks if condition matches current model.
// Condition supports wildcards: * matches any, ? matches single char.
EvaluateModelConditional func(condition string) bool
// RenderWithModelConditionals processes <if-model> blocks in content.
RenderWithModelConditionals func(content string) string
// -------------------------------------------------------------------------
// Model Resolution API (Phase 4 Bridge)
// -------------------------------------------------------------------------
// ResolveModelChain attempts each model in order until one is available.
ResolveModelChain func(preferences []string) ModelResolutionResult
// GetModelCapabilities returns capabilities for a specific model.
// If model is empty, uses current model.
GetModelCapabilities func(model string) (ModelCapabilities, string)
// CheckModelAvailable verifies if a model string is valid.
CheckModelAvailable func(model string) bool
// GetCurrentProvider returns just the provider part of current model.
GetCurrentProvider func() string
// GetCurrentModelID returns just the model ID part of current model.
GetCurrentModelID func() string
}
// ---------------------------------------------------------------------------
@@ -752,148 +598,6 @@ type SessionMessage struct {
Timestamp string
}
// ---------------------------------------------------------------------------
// Tree navigation types (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// TreeNode represents a node in the session tree for navigation.
// Extensions use this to traverse conversation history and implement
// features like "fresh context" loops and branch summarization.
type TreeNode struct {
// ID is the unique entry identifier.
ID string
// ParentID links this entry to its parent (empty if root).
ParentID string
// Type is the entry type: "message", "branch_summary", "model_change", "extension_data", "tool_execution".
Type string
// Role is the message role for message entries: "user", "assistant", "system", "tool".
Role string
// Content is the text content or summary.
Content string
// Model is the model that generated this (for assistant messages).
Model string
// Provider is the provider used.
Provider string
// Timestamp is the RFC3339-formatted creation time.
Timestamp string
// Children is the list of child entry IDs for tree traversal.
Children []string
}
// TreeNavigationResult reports success or failure of tree operations.
type TreeNavigationResult struct {
// Success is true if the operation completed.
Success bool
// Error describes what went wrong (empty if success).
Error string
}
// ---------------------------------------------------------------------------
// Skill types (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// Skill represents a loaded skill file with parsed YAML frontmatter.
type Skill struct {
// Name is the human-readable identifier.
Name string
// Description summarizes what this skill provides.
Description string
// Content is the markdown body (frontmatter stripped).
Content string
// Path is the absolute filesystem path.
Path string
// Tags are optional labels for categorization.
Tags []string
// When controls automatic inclusion: "always", "on-demand", or file-glob.
When string
}
// SkillLoadResult reports skills loaded from a directory.
type SkillLoadResult struct {
// Skills is the list of loaded skills.
Skills []Skill
// Error describes loading failures (empty if success).
Error string
}
// ---------------------------------------------------------------------------
// Template parsing types (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// PromptTemplate represents a parsed template with variable placeholders.
type PromptTemplate struct {
// Name is the template identifier.
Name string
// Content is the original template content.
Content string
// Variables are the extracted {{variable}} names.
Variables []string
}
// ArgumentPattern defines how to parse command arguments.
type ArgumentPattern struct {
// Positional names for $1, $2, etc.
Positional []string
// Rest is the variable name for $@ (all remaining).
Rest string
// Flags maps flag names to variable names (e.g., "--loop" -> "loop").
Flags map[string]string
}
// ParseResult reports argument parsing outcome.
type ParseResult struct {
// Vars maps variable names to values for positional args.
Vars map[string]string
// Flags maps flag names to values.
Flags map[string]string
// Rest is remaining unparsed text.
Rest string
// Error describes parsing failures (empty if success).
Error string
}
// ModelConditional represents an <if-model> block for evaluation.
type ModelConditional struct {
// Condition is the model pattern (e.g., "claude-*", "anthropic/*").
Condition string
// Content is rendered if condition matches.
Content string
// Else is rendered if condition doesn't match.
Else string
}
// ---------------------------------------------------------------------------
// Model resolution types (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// ModelCapabilities describes what a model supports.
type ModelCapabilities struct {
// Provider is the provider ID (e.g., "anthropic").
Provider string
// ModelID is the model identifier (e.g., "claude-sonnet-4-20250929").
ModelID string
// ContextLimit is the maximum context window in tokens.
ContextLimit int
// OutputLimit is the maximum output tokens.
OutputLimit int
// Reasoning indicates if the model supports reasoning/thinking.
Reasoning bool
// Streaming indicates if the model supports streaming.
Streaming bool
}
// ModelResolutionResult reports model chain resolution outcome.
type ModelResolutionResult struct {
// Model is the selected model in "provider/model" format.
Model string
// Capabilities describes the selected model.
Capabilities ModelCapabilities
// Attempted lists models tried before success.
Attempted []string
// Error describes resolution failures (empty if success).
Error string
}
// ExtensionEntry represents persisted extension data stored in the session.
// Extensions use AppendEntry to save custom state and GetEntries to retrieve
// it on session resume.
@@ -918,7 +622,7 @@ type ExtensionEntry struct {
type ContextMessage struct {
// Index is the position of this message in the original context array
// (0-based). When returning messages from a ContextPrepareResult,
// messages with Index >= 0 reuse the original LLM message at that
// messages with Index >= 0 reuse the original fantasy.Message at that
// position (preserving tool calls, reasoning, and other complex parts).
// Set Index to -1 for newly injected messages (created from Role + Content).
Index int
@@ -995,48 +699,6 @@ type StatusBarEntry struct {
Priority int
}
// CompactConfig configures a programmatic context compaction request.
type CompactConfig struct {
// CustomInstructions is optional text appended to the summary prompt
// (e.g. "Focus on the API design decisions"). Empty uses the default.
CustomInstructions string
// OnComplete is called when compaction finishes successfully.
// May be nil if the caller doesn't need notification.
OnComplete func()
// OnError is called when compaction fails. The argument is the error message.
// May be nil if the caller doesn't need notification.
OnError func(errMsg string)
}
// FilePart describes a file attachment for multimodal messages. Extensions
// use this with SendMultimodalMessage to attach images or documents.
type FilePart struct {
// Filename is the name of the file (e.g. "photo.jpg").
Filename string
// Data is the raw file content.
Data []byte
// MediaType is the MIME type (e.g. "image/jpeg", "application/pdf").
MediaType string
}
// SessionUsage contains aggregated token usage and cost statistics for
// the current session. Extensions use this with GetSessionUsage() to
// report usage information.
type SessionUsage struct {
// TotalInputTokens is the sum of input tokens across all requests.
TotalInputTokens int
// TotalOutputTokens is the sum of output tokens across all requests.
TotalOutputTokens int
// TotalCacheReadTokens is the sum of cache read tokens.
TotalCacheReadTokens int
// TotalCacheWriteTokens is the sum of cache write tokens.
TotalCacheWriteTokens int
// TotalCost is the total cost in USD across all requests.
TotalCost float64
// RequestCount is the number of LLM requests made in this session.
RequestCount int
}
// PrintBlockOpts configures a custom styled block for PrintBlock.
type PrintBlockOpts struct {
// Text is the main content to display.
@@ -1063,9 +725,6 @@ 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))
@@ -1094,14 +753,6 @@ 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)
}
// OnToolCall registers a handler that fires before a tool executes.
@@ -1110,26 +761,6 @@ 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)
@@ -1153,7 +784,7 @@ func (a *API) OnToolResult(handler func(ToolResultEvent, Context) *ToolResultRes
a.onToolResult(handler)
}
// OnSubagentStart registers a handler that fires when a subagent tool
// OnSubagentStart registers a handler that fires when a spawn_subagent tool
// call begins executing. Use the ToolCallID to correlate with subsequent
// OnSubagentChunk and OnSubagentEnd events for the same subagent.
func (a *API) OnSubagentStart(handler func(SubagentStartEvent, Context)) {
@@ -1168,7 +799,7 @@ func (a *API) OnSubagentChunk(handler func(SubagentChunkEvent, Context)) {
a.onSubagentChunk(handler)
}
// OnSubagentEnd registers a handler that fires when a subagent call
// OnSubagentEnd registers a handler that fires when a spawn_subagent call
// completes. ErrorMsg is non-empty when the subagent failed.
func (a *API) OnSubagentEnd(handler func(SubagentEndEvent, Context)) {
a.onSubagentEnd(handler)
@@ -1309,56 +940,6 @@ 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)
}
// 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
@@ -1971,34 +1552,6 @@ 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
@@ -2255,9 +1808,9 @@ func (BeforeCompactResult) isResult() {}
// Subagent lifecycle events (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// SubagentStartEvent fires when a subagent tool call begins executing.
// SubagentStartEvent fires when a spawn_subagent tool call begins executing.
type SubagentStartEvent struct {
// ToolCallID is the LLM-assigned ID of the subagent tool call.
// ToolCallID is the LLM-assigned ID of the spawn_subagent tool call.
// Use this to correlate SubagentChunkEvent and SubagentEndEvent.
ToolCallID string
// Task is the task description passed to the subagent.
@@ -2297,7 +1850,7 @@ type SubagentChunkEvent struct {
func (e SubagentChunkEvent) Type() EventType { return SubagentChunk }
// SubagentEndEvent fires when a subagent tool call completes.
// SubagentEndEvent fires when a spawn_subagent tool call completes.
type SubagentEndEvent struct {
// ToolCallID matches the SubagentStartEvent.ToolCallID for this subagent.
ToolCallID string
@@ -2311,98 +1864,6 @@ 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() {}
// 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 {
+3 -48
View File
@@ -13,19 +13,6 @@ 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"
@@ -85,7 +72,7 @@ const (
// cancel compaction by returning Cancel=true.
BeforeCompact EventType = "before_compact"
// SubagentStart fires when a subagent tool call begins executing.
// SubagentStart fires when a spawn_subagent tool call begins executing.
// Carries the tool call ID and the task description.
SubagentStart EventType = "subagent_start"
@@ -93,53 +80,21 @@ const (
// subagent: text chunks, tool calls, tool results, etc.
SubagentChunk EventType = "subagent_chunk"
// SubagentEnd fires when a subagent tool call completes (success
// SubagentEnd fires when a spawn_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"
)
// AllEventTypes returns every supported event type.
func AllEventTypes() []EventType {
return []EventType{
ToolCall, ToolCallInputStart, ToolCallInputDelta, ToolCallInputEnd,
ToolExecutionStart, ToolExecutionEnd, ToolResult,
ToolCall, 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,
}
}
+2 -5
View File
@@ -4,8 +4,8 @@ import "testing"
func TestAllEventTypes_Count(t *testing.T) {
all := AllEventTypes()
if len(all) != 32 {
t.Fatalf("expected 32 event types, got %d", len(all))
if len(all) != 21 {
t.Fatalf("expected 21 event types, got %d", len(all))
}
}
@@ -38,9 +38,6 @@ 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},
+45 -25
View File
@@ -154,11 +154,6 @@ func NewInstaller(projectDir string) *Installer {
// Install clones a git repository to the appropriate scope.
func (i *Installer) Install(source *GitSource, scope InstallScope) error {
return i.install(source, scope, nil)
}
// install is the internal implementation that supports optional include paths.
func (i *Installer) install(source *GitSource, scope InstallScope, includePaths []string) error {
targetDir := i.getInstallPath(source, scope)
// Check if already installed
@@ -204,7 +199,6 @@ func (i *Installer) install(source *GitSource, scope InstallScope, includePaths
Pinned: source.Pinned,
Scope: scope,
Installed: time.Now(),
Include: includePaths,
}
if err := i.addToManifest(entry, scope); err != nil {
// Don't fail the install, just log the error
@@ -274,22 +268,7 @@ func (i *Installer) Update(source *GitSource, scope InstallScope) error {
cleanCmd.Dir = targetDir
_ = cleanCmd.Run() // Ignore errors - clean is best effort
// Update manifest timestamp, preserving existing fields like Include
existing, _ := i.loadManifest(scope)
var include []string
var installed time.Time
if existing != nil {
for _, p := range existing.Packages {
if p.Host+"/"+p.Path == source.Identity() {
include = p.Include
installed = p.Installed
break
}
}
}
if installed.IsZero() {
installed = time.Now()
}
// Update manifest timestamp
entry := ManifestEntry{
Source: source.String(),
Repo: source.Repo,
@@ -298,9 +277,8 @@ func (i *Installer) Update(source *GitSource, scope InstallScope) error {
Ref: "",
Pinned: false,
Scope: scope,
Installed: installed,
Installed: time.Now(),
Updated: time.Now(),
Include: include,
}
_ = i.addToManifest(entry, scope) // Best effort - don't fail update if manifest fails
@@ -450,6 +428,25 @@ 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) {
@@ -506,7 +503,30 @@ func (i *Installer) PreviewExtensions(source *GitSource) ([]ExtensionPreview, st
// InstallWithInclude clones a repo and installs only the specified extensions.
// includePaths are relative paths like "./git/main.go" - if empty, installs all.
func (i *Installer) InstallWithInclude(source *GitSource, scope InstallScope, includePaths []string) error {
return i.install(source, scope, includePaths)
// First, do a regular install
if err := i.Install(source, scope); err != nil {
return err
}
// If specific includes were requested, update the manifest
if len(includePaths) > 0 {
entry := ManifestEntry{
Source: source.String(),
Repo: source.Repo,
Host: source.Host,
Path: source.Path,
Ref: source.Ref,
Pinned: source.Pinned,
Scope: scope,
Include: includePaths,
}
if err := addEntryToManifest(entry, scope); err != nil {
return fmt.Errorf("updating manifest with includes: %w", err)
}
}
return nil
}
// CleanupTempDir removes a temporary directory used for preview.
+47 -46
View File
@@ -245,21 +245,14 @@ 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 := installer.loadManifest(ScopeGlobal)
manifest, err := loadManifestFromPath(manifestPath)
if err != nil {
t.Fatalf("loadManifest() error = %v", err)
t.Fatalf("loadManifestFromPath() error = %v", err)
}
if len(manifest.Packages) != 0 {
t.Errorf("Expected empty packages, got %d", len(manifest.Packages))
@@ -280,20 +273,15 @@ func TestLoadAndSaveManifest(t *testing.T) {
}
// Save it
err = installer.saveManifest(manifest, ScopeGlobal)
err = saveManifestToPath(manifest, manifestPath)
if err != nil {
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)
t.Fatalf("saveManifestToPath() error = %v", err)
}
// Load it back
loaded, err := installer.loadManifest(ScopeGlobal)
loaded, err := loadManifestFromPath(manifestPath)
if err != nil {
t.Fatalf("loadManifest() error = %v", err)
t.Fatalf("loadManifestFromPath() error = %v", err)
}
if len(loaded.Packages) != 1 {
t.Errorf("Expected 1 package, got %d", len(loaded.Packages))
@@ -303,15 +291,21 @@ 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()
installer := &Installer{
projectGitRoot: tempDir,
globalGitRoot: tempDir,
// Set up environment for manifest path
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)
}
}()
// The manifest path when XDG_DATA_HOME is set
manifestPath := filepath.Join(tempDir, "kit", "git", "packages.json")
// Add an entry
entry := ManifestEntry{
@@ -321,51 +315,58 @@ func TestAddAndRemoveFromManifest(t *testing.T) {
Scope: ScopeGlobal,
}
if err := installer.addToManifest(entry, ScopeGlobal); err != nil {
t.Fatalf("addToManifest() error = %v", err)
err := addEntryToManifest(entry, ScopeGlobal)
if err != nil {
t.Fatalf("addEntryToManifest() error = %v", err)
}
// Verify it was added
manifest, err := installer.loadManifest(ScopeGlobal)
manifest, err := loadManifestFromPath(manifestPath)
if err != nil {
t.Fatalf("loadManifest() error = %v", err)
t.Fatalf("loadManifestFromPath() error = %v", err)
}
if len(manifest.Packages) != 1 {
t.Errorf("Expected 1 package, got %d", len(manifest.Packages))
}
// Remove it
if err := installer.removeFromManifest("github.com/user/repo", ScopeGlobal); err != nil {
t.Fatalf("removeFromManifest() error = %v", err)
err = removeEntryFromManifest("github.com/user/repo", ScopeGlobal)
if err != nil {
t.Fatalf("removeEntryFromManifest() error = %v", err)
}
// Verify it was removed
manifest, err = installer.loadManifest(ScopeGlobal)
manifest, err = loadManifestFromPath(manifestPath)
if err != nil {
t.Fatalf("loadManifest() error = %v", err)
t.Fatalf("loadManifestFromPath() 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()
t.Setenv("XDG_DATA_HOME", tempDir)
// 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)
if err := os.Setenv("XDG_DATA_HOME", tempDir); err != nil {
t.Fatalf("Setenv() 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)
defer func() {
if err := os.Unsetenv("XDG_DATA_HOME"); err != nil {
t.Logf("Unsetenv() error = %v", err)
}
}()
// Add an entry to global manifest
entry := ManifestEntry{
Source: "git:github.com/user/repo",
Host: "github.com",
Path: "user/repo",
Scope: ScopeGlobal,
}
err := addEntryToManifest(entry, ScopeGlobal)
if err != nil {
t.Fatalf("addEntryToManifest() error = %v", err)
}
// Find it
+38 -109
View File
@@ -34,64 +34,59 @@ func LoadExtensions(extraPaths []string) ([]LoadedExtension, error) {
for _, p := range paths {
ext, err := loadSingleExtension(p)
if err != nil {
log.Warn("skipping extension", "path", p, "err", err)
continue
}
loaded = append(loaded, *ext)
log.Debug("loaded extension", "path", p, "handlers", countHandlers(ext), "tools", len(ext.Tools), "commands", len(ext.Commands), "tool_renderers", len(ext.ToolRenderers))
log.Debug("loaded extension", "path", p,
"handlers", countHandlers(ext),
"tools", len(ext.Tools),
"commands", len(ext.Commands),
"tool_renderers", len(ext.ToolRenderers))
}
return loaded, nil
}
// pathSet is a thread-safe helper for deduplicating and ordering file paths.
type pathSet struct {
m map[string]bool
list []string
}
func newPathSet() *pathSet {
return &pathSet{m: make(map[string]bool)}
}
func (ps *pathSet) add(p string) bool {
abs, err := filepath.Abs(p)
if err != nil {
return false
}
if ps.m[abs] {
return false
}
ps.m[abs] = true
ps.list = append(ps.list, abs)
return true
}
// discoverExtensionPaths returns deduplicated paths to extension files in
// load-order (global first, then project-local, then explicit).
func discoverExtensionPaths(extraPaths []string) []string {
ps := newPathSet()
seen := make(map[string]bool)
var paths []string
add := func(p string) {
abs, err := filepath.Abs(p)
if err != nil {
return
}
if seen[abs] {
return
}
seen[abs] = true
paths = append(paths, abs)
}
// Global extensions: $XDG_CONFIG_HOME/kit/extensions/ (default ~/.config/kit/extensions/)
globalDir := globalExtensionsDir()
for _, p := range findExtensionsInDir(globalDir) {
ps.add(p)
add(p)
}
// Global installed git packages: $XDG_DATA_HOME/kit/git/
globalGitDir := globalGitInstallRoot()
for _, p := range findExtensionsInGitPackages(globalGitDir) {
ps.add(p)
add(p)
}
// Project-local extensions: .kit/extensions/
localDir := filepath.Join(".kit", "extensions")
for _, p := range findExtensionsInDir(localDir) {
ps.add(p)
add(p)
}
// Project-local installed git packages: .kit/git/
projectGitDir := filepath.Join(".kit", "git")
for _, p := range findExtensionsInGitPackages(projectGitDir) {
ps.add(p)
add(p)
}
// Explicit paths (highest precedence)
@@ -102,14 +97,14 @@ func discoverExtensionPaths(extraPaths []string) []string {
}
if info.IsDir() {
for _, found := range findExtensionsInDir(p) {
ps.add(found)
add(found)
}
} else if strings.HasSuffix(p, ".go") {
ps.add(p)
add(p)
}
}
return ps.list
return paths
}
// findExtensionsInDir returns .go files in dir and main.go in immediate subdirs.
@@ -128,7 +123,7 @@ func findExtensionsInDir(dir string) []string {
for _, entry := range entries {
full := filepath.Join(dir, entry.Name())
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".go") && !strings.HasSuffix(entry.Name(), "_test.go") {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".go") {
results = append(results, full)
} else if entry.IsDir() {
main := filepath.Join(full, "main.go")
@@ -185,13 +180,9 @@ func findExtensionsInRepo(repoPath string) []string {
isExtDir := base == "extensions" || base == "ext" ||
strings.HasSuffix(base, "-extensions") || strings.HasSuffix(base, "-ext")
// Allow walking into examples/ so we can reach examples/extensions/ etc,
// but don't treat examples/ itself or non-extension subdirs as extension locations.
if relPath == "examples" {
return nil
}
isExamplesSubdir := relPath == "examples" || strings.HasPrefix(relPath, "examples/")
if !isExtDir {
if !isExtDir && !isExamplesSubdir {
mainPath := filepath.Join(path, "main.go")
if _, err := os.Stat(mainPath); err == nil {
if relPath == base { // Top-level directory
@@ -201,6 +192,13 @@ func findExtensionsInRepo(repoPath string) []string {
}
return filepath.SkipDir
}
if isExamplesSubdir || isExtDir {
if !multiFileDirs[relPath] {
multiFileDirs[relPath] = true
results = append(results, mainPath)
}
return filepath.SkipDir
}
}
return filepath.SkipDir
}
@@ -219,7 +217,7 @@ func findExtensionsInRepo(repoPath string) []string {
}
// It's a file
if !strings.HasSuffix(info.Name(), ".go") || strings.HasSuffix(info.Name(), "_test.go") {
if !strings.HasSuffix(info.Name(), ".go") {
return nil
}
@@ -429,24 +427,6 @@ 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)
@@ -618,57 +598,6 @@ 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
})
},
}
// Call Init — the extension registers its handlers, tools, commands.
+89 -7
View File
@@ -72,6 +72,30 @@ 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 {
@@ -89,6 +113,55 @@ 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) {
@@ -180,13 +253,10 @@ func ScanForExtensions(dir string) ([]ExtensionPreview, error) {
isExtDir := base == "extensions" || base == "ext" ||
strings.HasSuffix(base, "-extensions") || strings.HasSuffix(base, "-ext")
// Allow walking into examples/ so we can reach examples/extensions/ etc,
// but don't treat examples/ itself or non-extension subdirs as extension locations.
if relPath == "examples" {
return nil
}
// Or check if it's a subdirectory of examples/ that might contain extensions
isExamplesSubdir := relPath == "examples" || strings.HasPrefix(relPath, "examples/")
if !isExtDir {
if !isExtDir && !isExamplesSubdir {
// Check for main.go before skipping
mainPath := filepath.Join(path, "main.go")
if _, err := os.Stat(mainPath); err == nil {
@@ -202,6 +272,18 @@ func ScanForExtensions(dir string) ([]ExtensionPreview, error) {
}
return filepath.SkipDir
}
// Inside a valid extensions directory
if isExamplesSubdir || isExtDir {
if !multiFileDirs[relPath] {
multiFileDirs[relPath] = true
previews = append(previews, ExtensionPreview{
Path: "./" + relPath + "/main.go",
Name: deriveExtensionName(relPath+"/main.go", true),
IsMain: true,
})
}
return filepath.SkipDir
}
}
// Not an extension location
@@ -227,7 +309,7 @@ func ScanForExtensions(dir string) ([]ExtensionPreview, error) {
}
// It's a file - check if it's a valid extension
if !strings.HasSuffix(info.Name(), ".go") || strings.HasSuffix(info.Name(), "_test.go") {
if !strings.HasSuffix(info.Name(), ".go") {
return nil
}
+11 -206
View File
@@ -1,93 +1,21 @@
package extensions
import (
"bytes"
"fmt"
"log"
"os"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"github.com/charmbracelet/log"
"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
@@ -124,11 +52,7 @@ type LoadedExtension struct {
// NewRunner creates a Runner from a set of loaded extensions.
func NewRunner(exts []LoadedExtension) *Runner {
mus := make([]reentrantMu, len(exts))
for i := range mus {
mus[i].init()
}
return &Runner{extensions: exts, extMu: mus}
return &Runner{extensions: exts}
}
// SetContext updates the runtime context (session ID, model, etc.) that is
@@ -162,21 +86,6 @@ func normalizeContext(ctx Context) Context {
if ctx.CancelAndSend == nil {
ctx.CancelAndSend = func(string) {}
}
if ctx.Abort == nil {
ctx.Abort = func() {}
}
if ctx.IsIdle == nil {
ctx.IsIdle = func() bool { return true }
}
if ctx.Compact == nil {
ctx.Compact = func(CompactConfig) error { return fmt.Errorf("compact not available") }
}
if ctx.SendMultimodalMessage == nil {
ctx.SendMultimodalMessage = func(string, []FilePart) {}
}
if ctx.GetSessionUsage == nil {
ctx.GetSessionUsage = func() SessionUsage { return SessionUsage{} }
}
if ctx.SetWidget == nil {
ctx.SetWidget = func(WidgetConfig) {}
}
@@ -305,102 +214,6 @@ func normalizeContext(ctx Context) Context {
return nil, nil, nil
}
}
// -------------------------------------------------------------------------
// Tree Navigation API no-ops
// -------------------------------------------------------------------------
if ctx.GetTreeNode == nil {
ctx.GetTreeNode = func(string) *TreeNode { return nil }
}
if ctx.GetCurrentBranch == nil {
ctx.GetCurrentBranch = func() []TreeNode { return nil }
}
if ctx.GetChildren == nil {
ctx.GetChildren = func(string) []string { return nil }
}
if ctx.NavigateTo == nil {
ctx.NavigateTo = func(string) TreeNavigationResult {
return TreeNavigationResult{Success: false, Error: "not implemented"}
}
}
if ctx.SummarizeBranch == nil {
ctx.SummarizeBranch = func(string, string) string {
return ""
}
}
if ctx.CollapseBranch == nil {
ctx.CollapseBranch = func(string, string, string) TreeNavigationResult {
return TreeNavigationResult{Success: false, Error: "not implemented"}
}
}
// -------------------------------------------------------------------------
// Skill Loading API no-ops
// -------------------------------------------------------------------------
if ctx.LoadSkill == nil {
ctx.LoadSkill = func(string) (*Skill, string) { return nil, "" }
}
if ctx.LoadSkillsFromDir == nil {
ctx.LoadSkillsFromDir = func(string) SkillLoadResult { return SkillLoadResult{} }
}
if ctx.DiscoverSkills == nil {
ctx.DiscoverSkills = func() SkillLoadResult { return SkillLoadResult{} }
}
if ctx.InjectSkillAsContext == nil {
ctx.InjectSkillAsContext = func(string) string { return "" }
}
if ctx.InjectRawSkillAsContext == nil {
ctx.InjectRawSkillAsContext = func(string) string { return "" }
}
if ctx.GetAvailableSkills == nil {
ctx.GetAvailableSkills = func() []Skill { return nil }
}
// -------------------------------------------------------------------------
// Template Parsing API no-ops
// -------------------------------------------------------------------------
if ctx.ParseTemplate == nil {
ctx.ParseTemplate = func(string, string) PromptTemplate { return PromptTemplate{} }
}
if ctx.RenderTemplate == nil {
ctx.RenderTemplate = func(PromptTemplate, map[string]string) string { return "" }
}
if ctx.ParseArguments == nil {
ctx.ParseArguments = func(string, ArgumentPattern) ParseResult { return ParseResult{} }
}
if ctx.SimpleParseArguments == nil {
ctx.SimpleParseArguments = func(string, int) []string { return nil }
}
if ctx.EvaluateModelConditional == nil {
ctx.EvaluateModelConditional = func(string) bool { return false }
}
if ctx.RenderWithModelConditionals == nil {
ctx.RenderWithModelConditionals = func(string) string { return "" }
}
// -------------------------------------------------------------------------
// Model Resolution API no-ops
// -------------------------------------------------------------------------
if ctx.ResolveModelChain == nil {
ctx.ResolveModelChain = func([]string) ModelResolutionResult {
return ModelResolutionResult{Error: "not implemented"}
}
}
if ctx.GetModelCapabilities == nil {
ctx.GetModelCapabilities = func(string) (ModelCapabilities, string) {
return ModelCapabilities{}, "not implemented"
}
}
if ctx.CheckModelAvailable == nil {
ctx.CheckModelAvailable = func(string) bool { return false }
}
if ctx.GetCurrentProvider == nil {
ctx.GetCurrentProvider = func() string { return "" }
}
if ctx.GetCurrentModelID == nil {
ctx.GetCurrentModelID = func() string { return "" }
}
return ctx
}
@@ -443,15 +256,13 @@ 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 {
log.Printf("WARN extension handler error: path=%s event=%s err=%v", ext.Path, event.Type(), err)
log.Warn("extension handler error",
"path", ext.Path,
"event", event.Type(),
"err", err)
continue
}
if result == nil {
@@ -460,7 +271,6 @@ func (r *Runner) Emit(event Event) (Result, error) {
// Check for blocking/short-circuit results.
if isBlocking(result) {
r.extMu[i].unlock()
return result, nil
}
@@ -468,7 +278,6 @@ 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
}
@@ -787,7 +596,9 @@ func (r *Runner) EmitCustomEvent(name, data string) {
safeInvoke := func(h func(string)) {
defer func() {
if rec := recover(); rec != nil {
log.Printf("WARN custom event handler panicked: event=%s err=%v", name, rec)
log.Warn("custom event handler panicked",
"event", name,
"err", fmt.Sprintf("%v", rec))
}
}()
h(data)
@@ -795,17 +606,11 @@ func (r *Runner) EmitCustomEvent(name, data string) {
// Extension-registered handlers first (in load order).
for i := range r.extensions {
extHandlers := r.extensions[i].CustomEventHandlers[name]
if len(extHandlers) == 0 {
continue
}
r.extMu[i].lock()
for _, h := range extHandlers {
for _, h := range r.extensions[i].CustomEventHandlers[name] {
safeInvoke(h)
}
r.extMu[i].unlock()
}
// Then dynamic subscriptions (not extension-scoped, no per-ext lock).
// Then dynamic subscriptions.
for _, h := range dynamicHandlers {
safeInvoke(h)
}
-140
View File
@@ -1,7 +1,6 @@
package extensions
import (
"sync"
"testing"
)
@@ -572,142 +571,3 @@ 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)
}
}
+225
View File
@@ -2,15 +2,22 @@
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).
@@ -150,3 +157,221 @@ 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 uint64
func generateSubagentID() string {
n := atomic.AddUint64(&subagentCounter, 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
}
-35
View File
@@ -31,7 +31,6 @@ func Symbols() interp.Exports {
// Session types
"SessionMessage": reflect.ValueOf((*SessionMessage)(nil)),
"ExtensionEntry": reflect.ValueOf((*ExtensionEntry)(nil)),
"SessionUsage": reflect.ValueOf((*SessionUsage)(nil)),
// Option types
"OptionDef": reflect.ValueOf((*OptionDef)(nil)),
@@ -45,8 +44,6 @@ func Symbols() interp.Exports {
// LLM completion types
"CompleteRequest": reflect.ValueOf((*CompleteRequest)(nil)),
"CompleteResponse": reflect.ValueOf((*CompleteResponse)(nil)),
"CompactConfig": reflect.ValueOf((*CompactConfig)(nil)),
"FilePart": reflect.ValueOf((*FilePart)(nil)),
// Status bar types
"StatusBarEntry": reflect.ValueOf((*StatusBarEntry)(nil)),
@@ -131,30 +128,9 @@ func Symbols() interp.Exports {
"ThemeColor": reflect.ValueOf((*ThemeColor)(nil)),
"ThemeColorConfig": reflect.ValueOf((*ThemeColorConfig)(nil)),
// Tree navigation types
"TreeNode": reflect.ValueOf((*TreeNode)(nil)),
"TreeNavigationResult": reflect.ValueOf((*TreeNavigationResult)(nil)),
// Skill types
"Skill": reflect.ValueOf((*Skill)(nil)),
"SkillLoadResult": reflect.ValueOf((*SkillLoadResult)(nil)),
// Template parsing types
"PromptTemplate": reflect.ValueOf((*PromptTemplate)(nil)),
"ArgumentPattern": reflect.ValueOf((*ArgumentPattern)(nil)),
"ParseResult": reflect.ValueOf((*ParseResult)(nil)),
"ModelConditional": reflect.ValueOf((*ModelConditional)(nil)),
// Model resolution types
"ModelCapabilities": reflect.ValueOf((*ModelCapabilities)(nil)),
"ModelResolutionResult": reflect.ValueOf((*ModelResolutionResult)(nil)),
// 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)),
@@ -172,17 +148,6 @@ 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)),
},
}
}
-192
View File
@@ -1,192 +0,0 @@
package extensions
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/fsnotify/fsnotify"
)
// 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
}
// 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()
}
// WatchedDirs returns the directories to watch for extension changes.
// This includes the global extensions directory and the project-local
// .kit/extensions/ directory (if they exist). Explicit -e paths that
// 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)
}
// Global extensions dir.
add(globalExtensionsDir())
// Project-local extensions dir.
add(filepath.Join(".kit", "extensions"))
// Explicit paths that are directories.
for _, p := range extraPaths {
info, err := os.Stat(p)
if err != nil {
continue
}
if info.IsDir() {
add(p)
} else {
// For explicit files, watch the parent directory.
add(filepath.Dir(p))
}
}
return dirs
}
-158
View File
@@ -1,158 +0,0 @@
package extensions
import (
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
)
func TestWatcher_ReloadsOnGoFileChange(t *testing.T) {
dir := t.TempDir()
// Write an initial extension file.
extFile := filepath.Join(dir, "test.go")
if err := os.WriteFile(extFile, []byte("package main\n"), 0o644); err != nil {
t.Fatal(err)
}
var reloadCount atomic.Int32
w, err := NewWatcher([]string{dir}, func() {
reloadCount.Add(1)
})
if err != nil {
t.Fatal(err)
}
go w.Start(t.Context())
// Modify the file.
time.Sleep(50 * time.Millisecond) // let watcher settle
if err := os.WriteFile(extFile, []byte("package main\n// changed\n"), 0o644); err != nil {
t.Fatal(err)
}
// Wait for debounce (300ms) + margin.
time.Sleep(600 * time.Millisecond)
if got := reloadCount.Load(); got != 1 {
t.Errorf("expected 1 reload, got %d", got)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
}
func TestWatcher_IgnoresNonGoFiles(t *testing.T) {
dir := t.TempDir()
var reloadCount atomic.Int32
w, err := NewWatcher([]string{dir}, func() {
reloadCount.Add(1)
})
if err != nil {
t.Fatal(err)
}
go w.Start(t.Context())
// Write a non-.go file.
time.Sleep(50 * time.Millisecond)
txtFile := filepath.Join(dir, "notes.txt")
if err := os.WriteFile(txtFile, []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}
// Wait past the debounce window.
time.Sleep(600 * time.Millisecond)
if got := reloadCount.Load(); got != 0 {
t.Errorf("expected 0 reloads for .txt file, got %d", got)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
}
func TestWatcher_Debounces(t *testing.T) {
dir := t.TempDir()
extFile := filepath.Join(dir, "ext.go")
if err := os.WriteFile(extFile, []byte("package main\n"), 0o644); err != nil {
t.Fatal(err)
}
var reloadCount atomic.Int32
w, err := NewWatcher([]string{dir}, func() {
reloadCount.Add(1)
})
if err != nil {
t.Fatal(err)
}
go w.Start(t.Context())
time.Sleep(50 * time.Millisecond)
// Rapid-fire writes (simulating editor save: write temp, rename, etc.).
for range 5 {
if err := os.WriteFile(extFile, []byte("package main\n// changed\n"), 0o644); err != nil {
t.Fatal(err)
}
time.Sleep(50 * time.Millisecond)
}
// Wait for debounce to fire.
time.Sleep(600 * time.Millisecond)
if got := reloadCount.Load(); got != 1 {
t.Errorf("expected 1 debounced reload, got %d", got)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
}
func TestWatchedDirs_Deduplicates(t *testing.T) {
dir := t.TempDir()
dirs := WatchedDirs([]string{dir, dir})
count := 0
for _, d := range dirs {
abs, _ := filepath.Abs(dir)
if d == abs {
count++
}
}
if count != 1 {
t.Errorf("expected directory to appear once, got %d", count)
}
}
func TestWatchedDirs_FileParent(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "ext.go")
if err := os.WriteFile(file, []byte("package main\n"), 0o644); err != nil {
t.Fatal(err)
}
dirs := WatchedDirs([]string{file})
abs, _ := filepath.Abs(dir)
found := false
for _, d := range dirs {
if d == abs {
found = true
}
}
if !found {
t.Errorf("expected parent dir %s in watched dirs %v", abs, dirs)
}
}
+18 -16
View File
@@ -28,11 +28,11 @@ func WrapToolsWithExtensions(tools []fantasy.AgentTool, runner *Runner) []fantas
return wrapped
}
// ExtensionToolsAsLLMTools converts ToolDef values registered by extensions
// into LLM agent tool implementations so the LLM can invoke them.
// ExtensionToolsAsFantasy converts ToolDef values registered by extensions
// into fantasy.AgentTool implementations so the LLM can invoke them.
// The runner is optional; if provided, ToolContext.OnProgress routes
// progress messages through the runner's Print function.
func ExtensionToolsAsLLMTools(defs []ToolDef, runner *Runner) []fantasy.AgentTool {
func ExtensionToolsAsFantasy(defs []ToolDef, runner *Runner) []fantasy.AgentTool {
tools := make([]fantasy.AgentTool, 0, len(defs))
for _, def := range defs {
tools = append(tools, &extensionTool{def: def, runner: runner})
@@ -42,14 +42,14 @@ func ExtensionToolsAsLLMTools(defs []ToolDef, runner *Runner) []fantasy.AgentToo
// 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",
"bash": "execute",
"edit": "edit",
"write": "edit",
"read": "read",
"ls": "read",
"grep": "search",
"find": "search",
"spawn_subagent": "agent",
}
// toolKindFor returns the ToolKind for a given tool name, defaulting to
@@ -90,7 +90,8 @@ 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)), nil
fmt.Sprintf("Error: tool %q is currently disabled", toolName)),
fmt.Errorf("tool %q disabled by extension", toolName)
}
kind := toolKindFor(toolName)
@@ -110,7 +111,8 @@ 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)), nil
return fantasy.NewTextErrorResponse(fmt.Sprintf("Error: %s", reason)),
fmt.Errorf("tool blocked by extension: %s", reason)
}
}
@@ -152,7 +154,7 @@ func (w *wrappedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.T
}
// ---------------------------------------------------------------------------
// extensionTool — wraps a ToolDef into an LLM agent tool
// extensionTool — wraps a ToolDef into a fantasy.AgentTool
// ---------------------------------------------------------------------------
type extensionTool struct {
@@ -180,7 +182,7 @@ func (t *extensionTool) Info() fantasy.ToolInfo {
info.Parameters = props
} else {
// Schema doesn't have "properties" — use as-is (may be
// a flat property map already matching the expected format).
// a flat property map already matching fantasy's format).
info.Parameters = schema
}
// Extract required fields if present.
@@ -236,7 +238,7 @@ func (t *extensionTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy
}
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
return fantasy.NewTextErrorResponse(err.Error()), err
}
return fantasy.NewTextResponse(result), nil
}
+12 -12
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 nil error for blocked tool (error is conveyed via IsError response)")
if err == nil {
t.Error("expected error from blocked tool")
}
if resp.IsError != true {
t.Error("expected IsError=true from blocked response")
@@ -192,7 +192,7 @@ func TestWrappedTool_ExecutionStartEnd(t *testing.T) {
}
}
func TestExtensionToolsAsLLMTools(t *testing.T) {
func TestExtensionToolsAsFantasy(t *testing.T) {
defs := []ToolDef{
{
Name: "greet",
@@ -202,7 +202,7 @@ func TestExtensionToolsAsLLMTools(t *testing.T) {
},
}
tools := ExtensionToolsAsLLMTools(defs, nil)
tools := ExtensionToolsAsFantasy(defs, nil)
if len(tools) != 1 {
t.Fatalf("expected 1 tool, got %d", len(tools))
}
@@ -232,10 +232,10 @@ func TestExtensionTool_Error(t *testing.T) {
},
}
tools := ExtensionToolsAsLLMTools(defs, nil)
tools := ExtensionToolsAsFantasy(defs, nil)
resp, err := tools[0].Run(context.Background(), fantasy.ToolCall{Input: "x"})
if err != nil {
t.Error("expected nil error (error is conveyed via IsError response)")
if err == nil {
t.Error("expected error")
}
if !resp.IsError {
t.Error("expected IsError=true")
@@ -259,7 +259,7 @@ func TestExtensionTool_ExecuteWithContext(t *testing.T) {
}
// Without runner, OnProgress is a no-op.
tools := ExtensionToolsAsLLMTools(defs, nil)
tools := ExtensionToolsAsFantasy(defs, nil)
resp, err := tools[0].Run(context.Background(), fantasy.ToolCall{Input: "test"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -285,7 +285,7 @@ func TestExtensionTool_ExecuteWithContext(t *testing.T) {
},
},
}
tools2 := ExtensionToolsAsLLMTools(defs2, runner)
tools2 := ExtensionToolsAsFantasy(defs2, runner)
_, err = tools2[0].Run(context.Background(), fantasy.ToolCall{Input: ""})
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -306,7 +306,7 @@ func TestExtensionTool_ExecuteWithContextPriority(t *testing.T) {
},
},
}
tools := ExtensionToolsAsLLMTools(defs, nil)
tools := ExtensionToolsAsFantasy(defs, nil)
resp, err := tools[0].Run(context.Background(), fantasy.ToolCall{Input: ""})
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -330,7 +330,7 @@ func TestExtensionTool_CancelledContext(t *testing.T) {
},
},
}
tools := ExtensionToolsAsLLMTools(defs, nil)
tools := ExtensionToolsAsFantasy(defs, nil)
_, _ = tools[0].Run(ctx, fantasy.ToolCall{Input: ""})
if !sawCancelled {
t.Error("expected IsCancelled=true for cancelled context")
@@ -339,7 +339,7 @@ func TestExtensionTool_CancelledContext(t *testing.T) {
func TestExtensionTool_ProviderOptions(t *testing.T) {
defs := []ToolDef{{Name: "test", Execute: func(string) (string, error) { return "", nil }}}
tools := ExtensionToolsAsLLMTools(defs, nil)
tools := ExtensionToolsAsFantasy(defs, nil)
// Initially nil.
opts := tools[0].ProviderOptions()
-248
View File
@@ -1,248 +0,0 @@
// Package fences provides utilities for detecting markdown code regions
// (fenced code blocks and inline code spans) and applying transformations
// only to text outside those regions.
//
// This prevents special tokens like $1, $@, or @file from being interpreted
// when they appear inside ``` fences, ~~~ fences, or `inline` code spans.
package fences
import "strings"
// Ranges returns byte ranges [start, end) of fenced code blocks in content.
// Recognises both backtick (```) and tilde (~~~) fences, with optional
// leading indentation (up to 3 spaces) and optional info strings.
// An unclosed fence extends to the end of content.
func Ranges(content string) [][2]int {
var result [][2]int
var inFence bool
var fenceChar byte
var fenceCount int
var fenceStart int
pos := 0
for pos < len(content) {
// Find the end of the current line.
lineEnd := strings.IndexByte(content[pos:], '\n')
var line string
var nextPos int
if lineEnd < 0 {
line = content[pos:]
nextPos = len(content)
} else {
line = content[pos : pos+lineEnd]
nextPos = pos + lineEnd + 1
}
trimmed := strings.TrimLeft(line, " ")
indent := len(line) - len(trimmed)
if !inFence {
if indent <= 3 {
if ch, n := parseFenceOpen(trimmed); n > 0 {
inFence = true
fenceChar = ch
fenceCount = n
fenceStart = pos
}
}
} else {
if indent <= 3 && isFenceClose(trimmed, fenceChar, fenceCount) {
result = append(result, [2]int{fenceStart, nextPos})
inFence = false
}
}
pos = nextPos
}
// Unclosed fence extends to end of content.
if inFence {
result = append(result, [2]int{fenceStart, len(content)})
}
return result
}
// ReplaceOutside applies fn to each text segment that is outside fenced code
// blocks and inline code spans, leaving code content unchanged. This is the
// primary entry point for callers that need to do regex replacement only on
// non-code text.
func ReplaceOutside(content string, fn func(string) string) string {
ranges := Ranges(content)
if len(ranges) == 0 {
return replaceOutsideInline(content, fn)
}
var b strings.Builder
b.Grow(len(content))
pos := 0
for _, r := range ranges {
if pos < r[0] {
// Within non-fenced segments, also skip inline code spans.
b.WriteString(replaceOutsideInline(content[pos:r[0]], fn))
}
// Preserve fenced content verbatim.
b.WriteString(content[r[0]:r[1]])
pos = r[1]
}
if pos < len(content) {
b.WriteString(replaceOutsideInline(content[pos:], fn))
}
return b.String()
}
// StripCode returns content with fenced code blocks and inline code spans
// removed. Useful for detection/matching where only non-code text matters.
func StripCode(content string) string {
// First strip fenced blocks.
stripped := StripFenced(content)
// Then strip inline code spans from what remains.
return stripInlineCode(stripped)
}
// StripFenced returns content with fenced code block regions removed.
// Useful for detection/matching where only non-fenced text matters.
// NOTE: this does NOT strip inline code spans; use StripCode for both.
func StripFenced(content string) string {
ranges := Ranges(content)
if len(ranges) == 0 {
return content
}
var b strings.Builder
b.Grow(len(content))
pos := 0
for _, r := range ranges {
b.WriteString(content[pos:r[0]])
pos = r[1]
}
b.WriteString(content[pos:])
return b.String()
}
// parseFenceOpen checks whether trimmed (leading spaces already removed)
// starts a fenced code block. Returns the fence character and count, or
// (0, 0) if it is not a fence opener.
func parseFenceOpen(trimmed string) (byte, int) {
if len(trimmed) == 0 {
return 0, 0
}
ch := trimmed[0]
if ch != '`' && ch != '~' {
return 0, 0
}
count := 0
for count < len(trimmed) && trimmed[count] == ch {
count++
}
if count < 3 {
return 0, 0
}
// Per CommonMark: backtick fences cannot have backticks in the info string.
if ch == '`' && strings.ContainsRune(trimmed[count:], '`') {
return 0, 0
}
return ch, count
}
// isFenceClose checks whether trimmed is a closing fence matching fenceChar
// with at least minCount characters. A closing fence line contains only the
// fence characters and optional trailing spaces.
func isFenceClose(trimmed string, fenceChar byte, minCount int) bool {
if len(trimmed) == 0 || trimmed[0] != fenceChar {
return false
}
count := 0
for count < len(trimmed) && trimmed[count] == fenceChar {
count++
}
if count < minCount {
return false
}
// Closing fence must contain only fence chars (and optional trailing spaces).
return strings.TrimRight(trimmed[count:], " ") == ""
}
// --------------------------------------------------------------------------
// Inline code span handling
// --------------------------------------------------------------------------
// inlineCodeRanges returns byte ranges [start, end) of inline code spans
// in segment. Per CommonMark, a code span opens with N backticks and closes
// with exactly N backticks.
func inlineCodeRanges(s string) [][2]int {
var result [][2]int
i := 0
for i < len(s) {
if s[i] != '`' {
i++
continue
}
// Count opening backticks.
start := i
n := 0
for i < len(s) && s[i] == '`' {
n++
i++
}
// Scan for a closing run of exactly n backticks.
for j := i; j < len(s); {
if s[j] != '`' {
j++
continue
}
m := 0
for j < len(s) && s[j] == '`' {
m++
j++
}
if m == n {
result = append(result, [2]int{start, j})
i = j
break
}
}
// If no closing run was found, i is already past the opening
// backticks so the outer loop advances naturally.
}
return result
}
// replaceOutsideInline applies fn only to text outside inline code spans.
func replaceOutsideInline(segment string, fn func(string) string) string {
ranges := inlineCodeRanges(segment)
if len(ranges) == 0 {
return fn(segment)
}
var b strings.Builder
b.Grow(len(segment))
pos := 0
for _, r := range ranges {
if pos < r[0] {
b.WriteString(fn(segment[pos:r[0]]))
}
b.WriteString(segment[r[0]:r[1]])
pos = r[1]
}
if pos < len(segment) {
b.WriteString(fn(segment[pos:]))
}
return b.String()
}
// stripInlineCode removes inline code spans from s.
func stripInlineCode(s string) string {
ranges := inlineCodeRanges(s)
if len(ranges) == 0 {
return s
}
var b strings.Builder
b.Grow(len(s))
pos := 0
for _, r := range ranges {
b.WriteString(s[pos:r[0]])
pos = r[1]
}
b.WriteString(s[pos:])
return b.String()
}
-313
View File
@@ -1,313 +0,0 @@
package fences
import (
"testing"
)
func TestRanges(t *testing.T) {
tests := []struct {
name string
content string
want [][2]int
}{
{
name: "no fences",
content: "hello world\nno code here",
want: nil,
},
{
name: "single backtick fence",
content: "before\n```\ncode\n```\nafter",
want: [][2]int{{7, 20}},
},
{
name: "single tilde fence",
content: "before\n~~~\ncode\n~~~\nafter",
want: [][2]int{{7, 20}},
},
{
name: "fence with info string",
content: "before\n```go\ncode\n```\nafter",
want: [][2]int{{7, 22}},
},
{
name: "multiple fences",
content: "a\n```\nx\n```\nb\n~~~\ny\n~~~\nc",
want: [][2]int{{2, 12}, {14, 24}},
},
{
name: "unclosed fence",
content: "before\n```\ncode\nmore code",
want: [][2]int{{7, 25}},
},
{
name: "longer closing fence",
content: "before\n```\ncode\n`````\nafter",
want: [][2]int{{7, 22}},
},
{
name: "shorter closing fence ignored",
content: "before\n`````\ncode\n```\nmore\n`````\nafter",
want: [][2]int{{7, 33}},
},
{
name: "indented fence up to 3 spaces",
content: "before\n ```\ncode\n ```\nafter",
want: [][2]int{{7, 26}},
},
{
name: "4 space indent is not a fence",
content: "before\n ```\ncode\n ```\nafter",
want: nil,
},
{
name: "backtick in info string rejects open",
// The ```foo`bar line is not a valid opener (backtick in info).
// The standalone ``` becomes an opener with no close.
content: "before\n```foo`bar\ncode\n```\nafter",
want: [][2]int{{23, 32}},
},
{
name: "empty content",
content: "",
want: nil,
},
{
name: "fence only",
content: "```\ncode\n```",
want: [][2]int{{0, 12}},
},
{
name: "fence at end without trailing newline",
content: "```\ncode\n```",
want: [][2]int{{0, 12}},
},
{
name: "tilde fence does not close with backticks",
content: "~~~\ncode\n```\nmore\n~~~\nafter",
want: [][2]int{{0, 22}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Ranges(tt.content)
if len(got) != len(tt.want) {
t.Fatalf("Ranges() = %v, want %v", got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("Ranges()[%d] = %v, want %v", i, got[i], tt.want[i])
}
}
})
}
}
func TestReplaceOutside(t *testing.T) {
upper := func(s string) string {
b := []byte(s)
for i, c := range b {
if c >= 'a' && c <= 'z' {
b[i] = c - 32
}
}
return string(b)
}
tests := []struct {
name string
content string
want string
}{
{
name: "no fences",
content: "hello world",
want: "HELLO WORLD",
},
{
name: "text around fence",
content: "before\n```\ncode\n```\nafter",
want: "BEFORE\n```\ncode\n```\nAFTER",
},
{
name: "multiple fences",
content: "aaa\n```\nxxx\n```\nbbb\n~~~\nyyy\n~~~\nccc",
want: "AAA\n```\nxxx\n```\nBBB\n~~~\nyyy\n~~~\nCCC",
},
{
name: "unclosed fence preserves code",
content: "before\n```\ncode",
want: "BEFORE\n```\ncode",
},
{
name: "only fenced content",
content: "```\ncode\n```",
want: "```\ncode\n```",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ReplaceOutside(tt.content, upper)
if got != tt.want {
t.Errorf("ReplaceOutside() =\n%s\nwant:\n%s", got, tt.want)
}
})
}
}
func TestStripFenced(t *testing.T) {
tests := []struct {
name string
content string
want string
}{
{
name: "no fences",
content: "hello $1 world",
want: "hello $1 world",
},
{
name: "strips fenced code",
content: "before $1\n```\n$2 inside\n```\nafter $3",
want: "before $1\nafter $3",
},
{
name: "multiple fences",
content: "a\n```\nx\n```\nb\n~~~\ny\n~~~\nc",
want: "a\nb\nc",
},
{
name: "unclosed fence",
content: "before\n```\n$1 inside",
want: "before\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := StripFenced(tt.content)
if got != tt.want {
t.Errorf("StripFenced() = %q, want %q", got, tt.want)
}
})
}
}
func TestInlineCodeRanges(t *testing.T) {
tests := []struct {
name string
s string
want [][2]int
}{
{"no backticks", "hello world", nil},
{"single backtick span", "use `$1` here", [][2]int{{4, 8}}},
{"double backtick span", "use ``$1`` here", [][2]int{{4, 10}}},
{"multiple spans", "`$1` and `$2`", [][2]int{{0, 4}, {9, 13}}},
{"unmatched backtick", "use `$1 here", nil},
{"mismatched backtick counts", "use ``$1` here", nil},
{"empty inline content", "use `` `` here", [][2]int{{4, 9}}},
{"backticks inside double", "use ``foo`bar`` here", [][2]int{{4, 15}}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := inlineCodeRanges(tt.s)
if len(got) != len(tt.want) {
t.Fatalf("inlineCodeRanges() = %v, want %v", got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("inlineCodeRanges()[%d] = %v, want %v", i, got[i], tt.want[i])
}
}
})
}
}
func TestReplaceOutside_InlineCode(t *testing.T) {
upper := func(s string) string {
b := []byte(s)
for i, c := range b {
if c >= 'a' && c <= 'z' {
b[i] = c - 32
}
}
return string(b)
}
tests := []struct {
name string
content string
want string
}{
{
name: "inline code preserved",
content: "use `code` here",
want: "USE `code` HERE",
},
{
name: "double backtick inline code",
content: "use ``co`de`` here",
want: "USE ``co`de`` HERE",
},
{
name: "mixed fenced and inline",
content: "before `x` mid\n```\nfenced\n```\nafter `y` end",
want: "BEFORE `x` MID\n```\nfenced\n```\nAFTER `y` END",
},
{
name: "only inline code",
content: "`code`",
want: "`code`",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ReplaceOutside(tt.content, upper)
if got != tt.want {
t.Errorf("ReplaceOutside() =\n%s\nwant:\n%s", got, tt.want)
}
})
}
}
func TestStripCode(t *testing.T) {
tests := []struct {
name string
content string
want string
}{
{
name: "no code",
content: "hello $1 world",
want: "hello $1 world",
},
{
name: "strips inline code",
content: "use `$1` and `$2` for positional args",
want: "use and for positional args",
},
{
name: "strips fenced and inline",
content: "before `$1`\n```\n$2 inside\n```\nafter",
want: "before \nafter",
},
{
name: "real world prompt template",
content: "Use $@ for all args.\n`$1`, `$2` for positional.\n```bash\necho $1\n```\n",
want: "Use $@ for all args.\n, for positional.\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := StripCode(tt.content)
if got != tt.want {
t.Errorf("StripCode() = %q, want %q", got, tt.want)
}
})
}
}
+24 -108
View File
@@ -33,10 +33,6 @@ type AgentSetupOptions struct {
// CoreTools overrides the default core tool set. If empty, core.AllTools()
// is used. Allows SDK users to pass custom tools (e.g. with WithWorkDir).
CoreTools []fantasy.AgentTool
// DisableCoreTools, when true, prevents loading any core tools.
// If both DisableCoreTools is true and CoreTools is empty, the agent
// will have no tools (useful for simple chat completions).
DisableCoreTools bool
// ExtraTools are additional tools added alongside core, MCP, and extension
// tools. They do not replace the defaults — they extend them.
ExtraTools []fantasy.AgentTool
@@ -44,37 +40,6 @@ type AgentSetupOptions struct {
// wrapping. Used by the SDK hook system. Both wrappers compose:
// extension wrapper runs first (inner), then this wrapper (outer).
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.
ProviderConfig *models.ProviderConfig
// Debug enables debug logging. When zero-value, viper is consulted.
// Only meaningful when ProviderConfig is also set.
Debug bool
// NoExtensions skips extension loading. When false, viper is consulted.
// Only meaningful when ProviderConfig is also set.
NoExtensions bool
// MaxSteps overrides the agent step limit. 0 means use viper value.
// Only meaningful when ProviderConfig is also set.
MaxSteps int
// StreamingEnabled controls streaming. Only meaningful when ProviderConfig
// is also set.
StreamingEnabled bool
// AuthHandler handles OAuth authorization for remote MCP servers.
// When set, remote transports are configured with OAuth support.
AuthHandler tools.MCPAuthHandler
// TokenStoreFactory, if non-nil, creates a custom token store for each
// remote MCP server's OAuth tokens. When nil, the default file-based
// token store is used.
TokenStoreFactory tools.TokenStoreFactory
// 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
}
// AgentSetupResult bundles the created agent and any debug logger so the caller
@@ -89,17 +54,15 @@ type AgentSetupResult struct {
// BuildProviderConfig creates a *models.ProviderConfig from the current viper
// state. 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"))
if err != nil {
return nil, "", fmt.Errorf("failed to load system prompt: %w", err)
}
temperature := float32(viper.GetFloat64("temperature"))
topP := float32(viper.GetFloat64("top-p"))
topK := int32(viper.GetInt("top-k"))
numGPU := int32(viper.GetInt("num-gpu-layers"))
mainGPU := int32(viper.GetInt("main-gpu"))
@@ -109,6 +72,9 @@ func BuildProviderConfig() (*models.ProviderConfig, string, error) {
ProviderAPIKey: viper.GetString("provider-api-key"),
ProviderURL: viper.GetString("provider-url"),
MaxTokens: viper.GetInt("max-tokens"),
Temperature: &temperature,
TopP: &topP,
TopK: &topK,
StopSequences: viper.GetStringSlice("stop-sequences"),
NumGPU: &numGPU,
MainGPU: &mainGPU,
@@ -116,66 +82,21 @@ func BuildProviderConfig() (*models.ProviderConfig, string, error) {
ThinkingLevel: models.ParseThinkingLevel(viper.GetString("thinking-level")),
}
// 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 viper.IsSet("top-p") {
v := float32(viper.GetFloat64("top-p"))
cfg.TopP = &v
}
if viper.IsSet("top-k") {
v := int32(viper.GetInt("top-k"))
cfg.TopK = &v
}
if viper.IsSet("frequency-penalty") {
v := float32(viper.GetFloat64("frequency-penalty"))
cfg.FrequencyPenalty = &v
}
if viper.IsSet("presence-penalty") {
v := float32(viper.GetFloat64("presence-penalty"))
cfg.PresencePenalty = &v
}
return cfg, systemPrompt, nil
}
// SetupAgent creates an agent from the current viper state + the provided
// options. It wraps BuildProviderConfig and agent.CreateAgent.
func SetupAgent(ctx context.Context, opts AgentSetupOptions) (*AgentSetupResult, error) {
var modelConfig *models.ProviderConfig
var systemPrompt string
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.
modelConfig = opts.ProviderConfig
systemPrompt = modelConfig.SystemPrompt
} else {
var err error
modelConfig, systemPrompt, err = BuildProviderConfig()
if err != nil {
return nil, err
}
modelConfig, systemPrompt, err := BuildProviderConfig()
if err != nil {
return nil, err
}
// 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")
maxSteps := opts.MaxSteps
if maxSteps == 0 {
maxSteps = viper.GetInt("max-steps")
}
streamingEnabled := opts.StreamingEnabled || viper.GetBool("stream")
// Create the appropriate debug logger.
var debugLogger tools.DebugLogger
var bufferedLogger *tools.BufferedDebugLogger
if debugEnabled {
if viper.GetBool("debug") {
if opts.UseBufferedLogger {
bufferedLogger = tools.NewBufferedDebugLogger(true)
debugLogger = bufferedLogger
@@ -187,7 +108,7 @@ func SetupAgent(ctx context.Context, opts AgentSetupOptions) (*AgentSetupResult,
// Load extensions unless --no-extensions is set.
var extRunner *extensions.Runner
var extCreationOpts extensionCreationOpts
if !noExtensions {
if !viper.GetBool("no-extensions") {
var extErr error
extRunner, extCreationOpts, extErr = loadExtensions()
if extErr != nil {
@@ -216,23 +137,18 @@ func SetupAgent(ctx context.Context, opts AgentSetupOptions) (*AgentSetupResult,
}
a, err := agent.CreateAgent(ctx, &agent.AgentCreationOptions{
ModelConfig: modelConfig,
MCPConfig: opts.MCPConfig,
SystemPrompt: systemPrompt,
MaxSteps: maxSteps,
StreamingEnabled: streamingEnabled,
ShowSpinner: opts.ShowSpinner,
Quiet: opts.Quiet,
SpinnerFunc: opts.SpinnerFunc,
DebugLogger: debugLogger,
AuthHandler: opts.AuthHandler,
TokenStoreFactory: opts.TokenStoreFactory,
CoreTools: opts.CoreTools,
DisableCoreTools: opts.DisableCoreTools,
ToolWrapper: toolWrapper,
ExtraTools: extraTools,
OnMCPServerLoaded: opts.OnMCPServerLoaded,
MCPTaskConfig: opts.MCPTaskConfig,
ModelConfig: modelConfig,
MCPConfig: opts.MCPConfig,
SystemPrompt: systemPrompt,
MaxSteps: viper.GetInt("max-steps"),
StreamingEnabled: viper.GetBool("stream"),
ShowSpinner: opts.ShowSpinner,
Quiet: opts.Quiet,
SpinnerFunc: opts.SpinnerFunc,
DebugLogger: debugLogger,
CoreTools: opts.CoreTools,
ToolWrapper: toolWrapper,
ExtraTools: extraTools,
})
if err != nil {
return nil, fmt.Errorf("failed to create agent: %w", err)
@@ -271,7 +187,7 @@ func loadExtensions() (*extensions.Runner, extensionCreationOpts, error) {
return extensions.WrapToolsWithExtensions(tools, runner)
}
extTools := extensions.ExtensionToolsAsLLMTools(runner.RegisteredTools(), runner)
extTools := extensions.ExtensionToolsAsFantasy(runner.RegisteredTools(), runner)
return runner, extensionCreationOpts{
toolWrapper: wrapper,
+10 -20
View File
@@ -4,18 +4,12 @@ import (
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"time"
"charm.land/fantasy"
)
// thinkTagRegex matches ... tags that some models (Qwen, DeepSeek) wrap
// reasoning content in. Used to strip these tags from text content.
// The (?s) flag makes . match newlines.
var thinkTagRegex = regexp.MustCompile(`(?s)` + `` + `think` + `` + `(.*?)` + `` + `/think` + ``)
// sanitizeToolCallID ensures the ID matches Anthropic's required pattern:
// ^[a-zA-Z0-9_-]+$ (alphanumeric, underscores, and hyphens only).
// Invalid characters are replaced with underscores.
@@ -121,9 +115,9 @@ const (
)
// Message is a single conversation message containing a heterogeneous slice
// of ContentPart blocks. This design enables a single assistant message to
// carry text, reasoning, and multiple tool calls as discrete, typed blocks
// rather than flattening everything into strings.
// of ContentPart blocks. This design (borrowed from crush) enables a single
// assistant message to carry text, reasoning, and multiple tool calls as
// discrete, typed blocks rather than flattening everything into strings.
type Message struct {
ID string `json:"id"`
Role MessageRole `json:"role"`
@@ -318,13 +312,13 @@ func UnmarshalParts(data []byte) ([]ContentPart, error) {
return parts, nil
}
// --- LLM bridge ---
// --- Fantasy bridge ---
// ToLLMMessages converts a Message to one or more LLM message values.
// An assistant message with tool calls produces a single message with
// ToFantasyMessages converts a Message to one or more fantasy.Message values.
// An assistant message with tool calls produces a single fantasy message with
// mixed TextPart and ToolCallPart content. Tool-role messages produce
// ToolResultPart entries.
func (m *Message) ToLLMMessages() []fantasy.Message {
func (m *Message) ToFantasyMessages() []fantasy.Message {
switch m.Role {
case RoleAssistant:
var parts []fantasy.MessagePart
@@ -422,9 +416,9 @@ func (m *Message) ToLLMMessages() []fantasy.Message {
}
}
// FromLLMMessage converts an LLM message into our Message type,
// FromFantasyMessage converts a fantasy.Message into our Message type,
// extracting all content parts into the appropriate block types.
func FromLLMMessage(msg fantasy.Message) Message {
func FromFantasyMessage(msg fantasy.Message) Message {
m := Message{
Role: MessageRole(msg.Role),
Parts: make([]ContentPart, 0),
@@ -436,11 +430,7 @@ func FromLLMMessage(msg fantasy.Message) Message {
switch p := part.(type) {
case fantasy.TextPart:
if p.Text != "" {
// Strip ... tags that some models wrap reasoning in
cleanedText := thinkTagRegex.ReplaceAllString(p.Text, "")
if cleanedText != "" {
m.Parts = append(m.Parts, TextContent{Text: cleanedText})
}
m.Parts = append(m.Parts, TextContent{Text: p.Text})
}
case fantasy.ToolCallPart:
m.Parts = append(m.Parts, ToolCall{
-70
View File
@@ -1,70 +0,0 @@
package models
import (
"crypto/sha256"
"encoding/hex"
"os"
"charm.land/fantasy"
"charm.land/fantasy/providers/openai"
)
// buildCacheProviderOptions returns caching options for supported models.
// Caching is enabled by default for all supported models to reduce costs.
// Set KIT_DISABLE_CACHE=1 or ProviderConfig.DisableCaching=true to opt out.
func buildCacheProviderOptions(modelInfo *ModelInfo, config *ProviderConfig) fantasy.ProviderOptions {
// Check explicit opt-out via config
if config.DisableCaching {
return nil
}
// Check global opt-out via environment
if os.Getenv("KIT_DISABLE_CACHE") != "" {
return nil
}
// Check if model supports caching
if modelInfo == nil || !modelInfo.SupportsCaching() {
return nil
}
switch modelInfo.CacheType() {
case "anthropic-ephemeral":
// Provider-level Anthropic caching disabled - use message-level caching instead.
return nil
case "openai-prompt-cache":
return buildOpenAICacheOptions(config, modelInfo.ID)
case "google-cached-content":
// Google caching not yet implemented.
return nil
default:
return nil
}
}
// buildOpenAICacheOptions enables prompt caching for OpenAI models.
// Uses a deterministic cache key based on system prompt and model ID.
func buildOpenAICacheOptions(config *ProviderConfig, modelID string) fantasy.ProviderOptions {
cacheKey := generateCacheKey(config.SystemPrompt, modelID)
return fantasy.ProviderOptions{
openai.Name: &openai.ProviderOptions{
PromptCacheKey: &cacheKey,
},
}
}
// generateCacheKey creates a deterministic cache key from system prompt and model.
// This ensures the same system prompt + model combination gets cache hits.
func generateCacheKey(systemPrompt, modelID string) string {
if systemPrompt == "" {
systemPrompt = "default"
}
h := sha256.New()
h.Write([]byte(systemPrompt))
h.Write([]byte(modelID))
// Prefix with "kit-" to identify KIT-generated cache keys
return "kit-" + hex.EncodeToString(h.Sum(nil))[:24]
}
-192
View File
@@ -1,192 +0,0 @@
package models
import (
"os"
"testing"
)
func TestModelInfo_SupportsCaching(t *testing.T) {
tests := []struct {
name string
family string
expected bool
}{
{"Claude model", "claude-3-5-sonnet", true},
{"Claude 4 model", "claude-4-opus", true},
{"GPT model", "gpt-4", true},
{"GPT-5 model", "gpt-5", true},
{"O1 model", "o1", true},
{"O3 model", "o3", true},
{"O4 model", "o4-mini", true},
{"Codex model", "codex", true},
{"Gemini model", "gemini-2.5-pro", true},
{"Gemini 1.5 model", "gemini-1.5-flash", true},
{"Llama model", "llama-3", false},
{"Unknown model", "unknown", false},
{"Empty family", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := &ModelInfo{Family: tt.family}
if got := m.SupportsCaching(); got != tt.expected {
t.Errorf("ModelInfo.SupportsCaching() = %v, want %v", got, tt.expected)
}
})
}
}
func TestModelInfo_CacheType(t *testing.T) {
tests := []struct {
name string
family string
expected string
}{
{"Claude model", "claude-3-5-sonnet", "anthropic-ephemeral"},
{"GPT model", "gpt-4", "openai-prompt-cache"},
{"O1 model", "o1", "openai-prompt-cache"},
{"Gemini model", "gemini-2.5-pro", "google-cached-content"},
{"Unknown model", "llama-3", ""},
{"Empty family", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := &ModelInfo{Family: tt.family}
if got := m.CacheType(); got != tt.expected {
t.Errorf("ModelInfo.CacheType() = %v, want %v", got, tt.expected)
}
})
}
}
func TestGenerateCacheKey(t *testing.T) {
key1 := generateCacheKey("system prompt", "model-id")
key2 := generateCacheKey("system prompt", "model-id")
if key1 != key2 {
t.Errorf("generateCacheKey should be deterministic: got %q and %q", key1, key2)
}
key3 := generateCacheKey("different prompt", "model-id")
if key1 == key3 {
t.Errorf("generateCacheKey should produce different keys for different inputs")
}
key4 := generateCacheKey("", "model-id")
key5 := generateCacheKey("default", "model-id")
if key4 != key5 {
t.Errorf("generateCacheKey should treat empty prompt as 'default'")
}
if len(key1) < 4 || key1[:4] != "kit-" {
t.Errorf("generateCacheKey should produce keys with 'kit-' prefix, got %q", key1)
}
}
func TestBuildCacheProviderOptions_Disabled(t *testing.T) {
config := &ProviderConfig{DisableCaching: true}
modelInfo := &ModelInfo{Family: "claude-3", ID: "claude-3-opus"}
if opts := buildCacheProviderOptions(modelInfo, config); opts != nil {
t.Errorf("buildCacheProviderOptions should return nil when DisableCaching=true")
}
}
func TestBuildCacheProviderOptions_EnvironmentVariable(t *testing.T) {
_ = os.Setenv("KIT_DISABLE_CACHE", "1")
defer func() { _ = os.Unsetenv("KIT_DISABLE_CACHE") }()
config := &ProviderConfig{DisableCaching: false}
modelInfo := &ModelInfo{Family: "claude-3", ID: "claude-3-opus"}
if opts := buildCacheProviderOptions(modelInfo, config); opts != nil {
t.Errorf("buildCacheProviderOptions should return nil when KIT_DISABLE_CACHE is set")
}
}
func TestBuildCacheProviderOptions_UnsupportedModel(t *testing.T) {
config := &ProviderConfig{DisableCaching: false}
modelInfo := &ModelInfo{Family: "llama-3", ID: "llama-3-70b"}
if opts := buildCacheProviderOptions(modelInfo, config); opts != nil {
t.Errorf("buildCacheProviderOptions should return nil for unsupported model families")
}
}
func TestBuildCacheProviderOptions_NilModelInfo(t *testing.T) {
config := &ProviderConfig{DisableCaching: false}
if opts := buildCacheProviderOptions(nil, config); opts != nil {
t.Errorf("buildCacheProviderOptions should return nil when modelInfo is nil")
}
}
func TestBuildCacheProviderOptions_Anthropic(t *testing.T) {
_ = os.Unsetenv("KIT_DISABLE_CACHE")
config := &ProviderConfig{DisableCaching: false}
modelInfo := &ModelInfo{Family: "claude-3", ID: "claude-3-opus"}
opts := buildCacheProviderOptions(modelInfo, config)
// Provider-level Anthropic caching is disabled; message-level caching is used instead
if opts != nil {
t.Logf("Provider-level Anthropic caching disabled; using message-level caching")
}
}
func TestBuildCacheProviderOptions_OpenAI(t *testing.T) {
_ = os.Unsetenv("KIT_DISABLE_CACHE")
config := &ProviderConfig{
DisableCaching: false,
SystemPrompt: "test system prompt",
}
modelInfo := &ModelInfo{Family: "gpt-4", ID: "gpt-4o"}
opts := buildCacheProviderOptions(modelInfo, config)
if opts == nil {
t.Fatalf("buildCacheProviderOptions should return options for OpenAI models")
}
if _, ok := opts["openai"]; !ok {
t.Errorf("buildCacheProviderOptions should include 'openai' key for GPT models")
}
}
func TestCachingPriorityOverThinking(t *testing.T) {
_ = os.Unsetenv("KIT_DISABLE_CACHE")
// Anthropic uses message-level caching; provider-level returns nil
config1 := &ProviderConfig{
DisableCaching: false,
ThinkingLevel: ThinkingOff,
}
modelInfo1 := &ModelInfo{Family: "claude-3", ID: "claude-3-opus"}
opts1 := buildCacheProviderOptions(modelInfo1, config1)
if opts1 != nil {
t.Logf("Provider-level Anthropic caching disabled; using message-level caching")
}
// OpenAI provider-level caching works with thinking enabled
config2 := &ProviderConfig{
DisableCaching: false,
SystemPrompt: "test prompt",
ThinkingLevel: ThinkingMedium,
}
modelInfo2 := &ModelInfo{Family: "gpt-4", ID: "gpt-4o"}
opts2 := buildCacheProviderOptions(modelInfo2, config2)
if opts2 == nil {
t.Errorf("OpenAI caching should work with thinking enabled")
}
// OpenAI caching also works with thinking disabled
config3 := &ProviderConfig{
DisableCaching: false,
SystemPrompt: "test prompt",
ThinkingLevel: ThinkingOff,
}
opts3 := buildCacheProviderOptions(modelInfo2, config3)
if opts3 == nil {
t.Errorf("OpenAI caching should work when thinking is OFF")
}
}
+9 -236
View File
@@ -2,8 +2,6 @@ package models
import (
"log"
"os"
"strings"
"github.com/spf13/viper"
)
@@ -33,14 +31,12 @@ func loadCustomModelsFromConfig() map[string]ModelInfo {
// modelConfigToModelInfo converts a CustomModelConfig to a ModelInfo.
func modelConfigToModelInfo(modelID string, cfg CustomModelConfig) ModelInfo {
info := ModelInfo{
return ModelInfo{
ID: modelID,
Name: cfg.Name,
Attachment: cfg.Attachment,
Reasoning: cfg.Reasoning,
Temperature: cfg.Temperature,
BaseURL: cfg.BaseURL,
APIKey: cfg.APIKey,
Cost: Cost{
Input: cfg.Cost.Input,
Output: cfg.Cost.Output,
@@ -50,242 +46,19 @@ func modelConfigToModelInfo(modelID string, cfg CustomModelConfig) ModelInfo {
Output: cfg.Limit.Output,
},
}
// Convert custom model generation params if any are set.
if p := convertGenerationParams(cfg.Params); p != nil {
info.Params = p
}
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") {
return nil
}
var settings map[string]GenerationParamsConfig
if err := viper.UnmarshalKey("modelSettings", &settings); err != nil {
log.Printf("Warning: Failed to parse modelSettings: %v", err)
return nil
}
result := make(map[string]*GenerationParams, len(settings))
for modelKey, cfg := range settings {
if p := convertGenerationParams(cfg); p != nil {
result[modelKey] = p
}
}
return result
}
// convertGenerationParams converts a GenerationParamsConfig to a GenerationParams.
// Returns nil if no parameters are set.
func convertGenerationParams(cfg GenerationParamsConfig) *GenerationParams {
p := &GenerationParams{}
any := false
if cfg.MaxTokens != nil {
p.MaxTokens = cfg.MaxTokens
any = true
}
if cfg.Temperature != nil {
p.Temperature = cfg.Temperature
any = true
}
if cfg.TopP != nil {
p.TopP = cfg.TopP
any = true
}
if cfg.TopK != nil {
p.TopK = cfg.TopK
any = true
}
if cfg.FrequencyPenalty != nil {
p.FrequencyPenalty = cfg.FrequencyPenalty
any = true
}
if cfg.PresencePenalty != nil {
p.PresencePenalty = cfg.PresencePenalty
any = true
}
if len(cfg.StopSequences) > 0 {
p.StopSequences = cfg.StopSequences
any = true
}
if cfg.ThinkingLevel != "" {
p.ThinkingLevel = ParseThinkingLevel(cfg.ThinkingLevel)
any = true
}
if cfg.SystemPrompt != "" {
p.SystemPrompt = cfg.SystemPrompt
any = true
}
if !any {
return nil
}
return p
}
// ApplyModelSettings merges per-model generation parameter defaults from the
// registry into a ProviderConfig. Model-level params are only applied for
// fields where the user has not explicitly set a value (i.e., the
// corresponding viper key is not set via CLI flag or global config).
//
// The lookup order is:
// 1. modelSettings["provider/model"] from config (highest model-level priority)
// 2. ModelInfo.Params from custom model definitions
//
// Both are overridden by explicit CLI flags / global config values.
func ApplyModelSettings(config *ProviderConfig, modelInfo *ModelInfo) {
provider, modelName, err := ParseModelString(config.ModelString)
if err != nil {
return
}
// 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 {
modelKey := provider + "/" + modelName
if p, ok := settings[modelKey]; ok {
params = p
}
}
// Fall back to ModelInfo.Params (from custom model definitions).
if params == nil && modelInfo != nil && modelInfo.Params != nil {
params = modelInfo.Params
}
if params == nil {
return
}
// Apply each parameter only when the user hasn't explicitly set it.
// 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") {
config.MaxTokens = *params.MaxTokens
}
if params.Temperature != nil && !isExplicitlySet("temperature") {
config.Temperature = params.Temperature
}
if params.TopP != nil && !isExplicitlySet("top-p") {
config.TopP = params.TopP
}
if params.TopK != nil && !isExplicitlySet("top-k") {
config.TopK = params.TopK
}
if params.FrequencyPenalty != nil && !isExplicitlySet("frequency-penalty") {
config.FrequencyPenalty = params.FrequencyPenalty
}
if params.PresencePenalty != nil && !isExplicitlySet("presence-penalty") {
config.PresencePenalty = params.PresencePenalty
}
if len(params.StopSequences) > 0 && !isExplicitlySet("stop-sequences") {
config.StopSequences = params.StopSequences
}
if params.ThinkingLevel != "" && !isExplicitlySet("thinking-level") {
config.ThinkingLevel = params.ThinkingLevel
}
if params.SystemPrompt != "" && config.SystemPrompt == "" {
// Resolve file paths: if the value points to an existing file, read it.
// We check config.SystemPrompt == "" rather than isExplicitlySet because
// viper.BindPFlag causes IsSet to return true even for unset flags.
config.SystemPrompt = LoadSystemPromptValue(params.SystemPrompt)
}
}
// LoadSystemPromptValue resolves a system prompt value that may be either
// inline text or a file path. If the value is a path to an existing file,
// its contents are read and returned. Otherwise the string is returned as-is.
// This mirrors config.LoadSystemPrompt but lives in the models package to
// avoid circular dependencies.
func LoadSystemPromptValue(input string) string {
if input == "" {
return ""
}
if info, err := os.Stat(input); err == nil && !info.IsDir() {
content, err := os.ReadFile(input)
if err != nil {
log.Printf("Warning: failed to read system prompt file %q: %v", input, err)
return input
}
return strings.TrimSpace(string(content))
}
return input
}
// 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 {
// 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
// as a default). For generation params, the global config keys use
// hyphenated names (e.g. "max-tokens", "top-p").
//
// Since viper merges all sources, IsSet returns true even for config
// 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)
}
// GenerationParams holds per-model generation parameter defaults.
// These are stored on ModelInfo and applied during provider creation.
// Nil pointer fields mean "no model-level default" — the global config
// or CLI flag value (if any) will be used instead.
type GenerationParams struct {
MaxTokens *int
Temperature *float32
TopP *float32
TopK *int32
FrequencyPenalty *float32
PresencePenalty *float32
StopSequences []string
ThinkingLevel ThinkingLevel
SystemPrompt string // Per-model system prompt (inline text or file path)
}
// CustomModelConfig defines a custom model configuration loaded from the config file.
// This is a duplicate here to avoid circular dependencies with internal/config.
type CustomModelConfig struct {
Name string `json:"name" yaml:"name"`
BaseURL string `json:"baseUrl,omitempty" yaml:"baseUrl,omitempty"`
APIKey string `json:"apiKey,omitempty" yaml:"apiKey,omitempty"`
Family string `json:"family,omitempty" yaml:"family,omitempty"`
Attachment bool `json:"attachment,omitempty" yaml:"attachment,omitempty"`
Reasoning bool `json:"reasoning,omitempty" yaml:"reasoning,omitempty"`
Temperature bool `json:"temperature,omitempty" yaml:"temperature,omitempty"`
Knowledge string `json:"knowledge,omitempty" yaml:"knowledge,omitempty"`
Cost CostConfig `json:"cost" yaml:"cost"`
Limit LimitConfig `json:"limit" yaml:"limit"`
Params GenerationParamsConfig `json:"params,omitzero" yaml:"params,omitempty"`
}
// GenerationParamsConfig is the JSON/YAML-serializable form of generation
// parameter defaults. Used in both customModels[].params and modelSettings[].
type GenerationParamsConfig struct {
MaxTokens *int `json:"maxTokens,omitempty" yaml:"maxTokens,omitempty"`
Temperature *float32 `json:"temperature,omitempty" yaml:"temperature,omitempty"`
TopP *float32 `json:"topP,omitempty" yaml:"topP,omitempty"`
TopK *int32 `json:"topK,omitempty" yaml:"topK,omitempty"`
FrequencyPenalty *float32 `json:"frequencyPenalty,omitempty" yaml:"frequencyPenalty,omitempty"`
PresencePenalty *float32 `json:"presencePenalty,omitempty" yaml:"presencePenalty,omitempty"`
StopSequences []string `json:"stopSequences,omitempty" yaml:"stopSequences,omitempty"`
ThinkingLevel string `json:"thinkingLevel,omitempty" yaml:"thinkingLevel,omitempty"`
SystemPrompt string `json:"systemPrompt,omitempty" yaml:"systemPrompt,omitempty"`
Name string `json:"name" yaml:"name"`
Family string `json:"family,omitempty" yaml:"family,omitempty"`
Attachment bool `json:"attachment,omitempty" yaml:"attachment,omitempty"`
Reasoning bool `json:"reasoning,omitempty" yaml:"reasoning,omitempty"`
Temperature bool `json:"temperature,omitempty" yaml:"temperature,omitempty"`
Knowledge string `json:"knowledge,omitempty" yaml:"knowledge,omitempty"`
Cost CostConfig `json:"cost" yaml:"cost"`
Limit LimitConfig `json:"limit" yaml:"limit"`
}
// CostConfig defines the pricing for a custom model.
-422
View File
@@ -1,422 +0,0 @@
package models
import (
"os"
"testing"
"github.com/spf13/viper"
)
func TestConvertGenerationParams(t *testing.T) {
t.Run("empty config returns nil", func(t *testing.T) {
cfg := GenerationParamsConfig{}
p := convertGenerationParams(cfg)
if p != nil {
t.Errorf("expected nil, got %+v", p)
}
})
t.Run("temperature only", func(t *testing.T) {
temp := float32(0.7)
cfg := GenerationParamsConfig{Temperature: &temp}
p := convertGenerationParams(cfg)
if p == nil {
t.Fatal("expected non-nil")
}
if p.Temperature == nil || *p.Temperature != 0.7 {
t.Errorf("expected temperature 0.7, got %v", p.Temperature)
}
if p.TopP != nil {
t.Errorf("expected nil TopP, got %v", p.TopP)
}
})
t.Run("all params set", func(t *testing.T) {
maxTokens := 8192
temp := float32(0.5)
topP := float32(0.9)
topK := int32(50)
freqPenalty := float32(0.1)
presPenalty := float32(0.2)
cfg := GenerationParamsConfig{
MaxTokens: &maxTokens,
Temperature: &temp,
TopP: &topP,
TopK: &topK,
FrequencyPenalty: &freqPenalty,
PresencePenalty: &presPenalty,
StopSequences: []string{"STOP"},
ThinkingLevel: "high",
}
p := convertGenerationParams(cfg)
if p == nil {
t.Fatal("expected non-nil")
}
if p.MaxTokens == nil || *p.MaxTokens != 8192 {
t.Errorf("expected maxTokens 8192, got %v", p.MaxTokens)
}
if p.Temperature == nil || *p.Temperature != 0.5 {
t.Errorf("expected temperature 0.5, got %v", p.Temperature)
}
if p.TopP == nil || *p.TopP != 0.9 {
t.Errorf("expected topP 0.9, got %v", p.TopP)
}
if p.TopK == nil || *p.TopK != 50 {
t.Errorf("expected topK 50, got %v", p.TopK)
}
if p.FrequencyPenalty == nil || *p.FrequencyPenalty != 0.1 {
t.Errorf("expected frequencyPenalty 0.1, got %v", p.FrequencyPenalty)
}
if p.PresencePenalty == nil || *p.PresencePenalty != 0.2 {
t.Errorf("expected presencePenalty 0.2, got %v", p.PresencePenalty)
}
if len(p.StopSequences) != 1 || p.StopSequences[0] != "STOP" {
t.Errorf("expected stop sequences [STOP], got %v", p.StopSequences)
}
if p.ThinkingLevel != ThinkingHigh {
t.Errorf("expected thinking level high, got %v", p.ThinkingLevel)
}
})
t.Run("thinking level parsing", func(t *testing.T) {
cfg := GenerationParamsConfig{ThinkingLevel: "medium"}
p := convertGenerationParams(cfg)
if p == nil {
t.Fatal("expected non-nil")
}
if p.ThinkingLevel != ThinkingMedium {
t.Errorf("expected thinking level medium, got %v", p.ThinkingLevel)
}
})
t.Run("system prompt only", func(t *testing.T) {
cfg := GenerationParamsConfig{SystemPrompt: "You are helpful."}
p := convertGenerationParams(cfg)
if p == nil {
t.Fatal("expected non-nil")
}
if p.SystemPrompt != "You are helpful." {
t.Errorf("expected system prompt, got %q", p.SystemPrompt)
}
})
}
func TestModelConfigToModelInfoWithParams(t *testing.T) {
temp := float32(0.8)
topP := float32(0.95)
cfg := CustomModelConfig{
Name: "Test Model",
BaseURL: "http://localhost:8080/v1",
Temperature: true,
Params: GenerationParamsConfig{
Temperature: &temp,
TopP: &topP,
},
}
info := modelConfigToModelInfo("test-model", cfg)
if info.Params == nil {
t.Fatal("expected non-nil Params")
}
if info.Params.Temperature == nil || *info.Params.Temperature != 0.8 {
t.Errorf("expected temperature 0.8, got %v", info.Params.Temperature)
}
if info.Params.TopP == nil || *info.Params.TopP != 0.95 {
t.Errorf("expected topP 0.95, got %v", info.Params.TopP)
}
}
func TestModelConfigToModelInfoWithoutParams(t *testing.T) {
cfg := CustomModelConfig{
Name: "Test Model",
BaseURL: "http://localhost:8080/v1",
}
info := modelConfigToModelInfo("test-model", cfg)
if info.Params != nil {
t.Errorf("expected nil Params, got %+v", info.Params)
}
}
func TestApplyModelSettings(t *testing.T) {
// Save and restore viper state.
originalViper := viper.AllSettings()
defer func() {
viper.Reset()
for k, v := range originalViper {
viper.Set(k, v)
}
}()
t.Run("applies model params when not explicitly set", func(t *testing.T) {
viper.Reset()
temp := float32(0.8)
topK := int32(50)
maxTokens := 4096
modelInfo := &ModelInfo{
ID: "test-model",
Params: &GenerationParams{
Temperature: &temp,
TopK: &topK,
MaxTokens: &maxTokens,
},
}
config := &ProviderConfig{
ModelString: "custom/test-model",
}
ApplyModelSettings(config, modelInfo)
if config.Temperature == nil || *config.Temperature != 0.8 {
t.Errorf("expected temperature 0.8, got %v", config.Temperature)
}
if config.TopK == nil || *config.TopK != 50 {
t.Errorf("expected topK 50, got %v", config.TopK)
}
if config.MaxTokens != 4096 {
t.Errorf("expected maxTokens 4096, got %d", config.MaxTokens)
}
})
t.Run("explicit viper values take precedence", func(t *testing.T) {
viper.Reset()
viper.Set("temperature", 0.3)
temp := float32(0.8)
modelInfo := &ModelInfo{
ID: "test-model",
Params: &GenerationParams{
Temperature: &temp,
},
}
explicitTemp := float32(0.3)
config := &ProviderConfig{
ModelString: "custom/test-model",
Temperature: &explicitTemp,
}
ApplyModelSettings(config, modelInfo)
// Temperature should NOT be overridden because it's explicitly set in viper
if config.Temperature == nil || *config.Temperature != 0.3 {
t.Errorf("expected temperature 0.3 (explicit), got %v", config.Temperature)
}
})
t.Run("nil model info is safe", func(t *testing.T) {
viper.Reset()
config := &ProviderConfig{
ModelString: "custom/test-model",
}
// Should not panic
ApplyModelSettings(config, nil)
if config.Temperature != nil {
t.Errorf("expected nil temperature, got %v", config.Temperature)
}
})
t.Run("model info without params is safe", func(t *testing.T) {
viper.Reset()
modelInfo := &ModelInfo{ID: "test-model"}
config := &ProviderConfig{
ModelString: "custom/test-model",
}
ApplyModelSettings(config, modelInfo)
if config.Temperature != nil {
t.Errorf("expected nil temperature, got %v", config.Temperature)
}
})
t.Run("modelSettings from viper takes priority over ModelInfo.Params", func(t *testing.T) {
viper.Reset()
// Set up modelSettings in viper (simulating config file)
viper.Set("modelSettings", map[string]any{
"custom/test-model": map[string]any{
"temperature": 0.5,
"topK": 30,
},
})
// ModelInfo has different params
temp := float32(0.8)
topK := int32(50)
modelInfo := &ModelInfo{
ID: "test-model",
Params: &GenerationParams{
Temperature: &temp,
TopK: &topK,
},
}
config := &ProviderConfig{
ModelString: "custom/test-model",
}
ApplyModelSettings(config, modelInfo)
// modelSettings should win over ModelInfo.Params
if config.Temperature == nil || *config.Temperature != 0.5 {
t.Errorf("expected temperature 0.5 (from modelSettings), got %v", config.Temperature)
}
if config.TopK == nil || *config.TopK != 30 {
t.Errorf("expected topK 30 (from modelSettings), got %v", config.TopK)
}
})
t.Run("stop sequences applied from model params", func(t *testing.T) {
viper.Reset()
modelInfo := &ModelInfo{
ID: "test-model",
Params: &GenerationParams{
StopSequences: []string{"STOP", "END"},
},
}
config := &ProviderConfig{
ModelString: "custom/test-model",
}
ApplyModelSettings(config, modelInfo)
if len(config.StopSequences) != 2 || config.StopSequences[0] != "STOP" {
t.Errorf("expected stop sequences [STOP END], got %v", config.StopSequences)
}
})
t.Run("thinking level applied from model params", func(t *testing.T) {
viper.Reset()
modelInfo := &ModelInfo{
ID: "test-model",
Params: &GenerationParams{
ThinkingLevel: ThinkingHigh,
},
}
config := &ProviderConfig{
ModelString: "custom/test-model",
}
ApplyModelSettings(config, modelInfo)
if config.ThinkingLevel != ThinkingHigh {
t.Errorf("expected thinking level high, got %v", config.ThinkingLevel)
}
})
t.Run("system prompt applied from model params", func(t *testing.T) {
viper.Reset()
modelInfo := &ModelInfo{
ID: "test-model",
Params: &GenerationParams{
SystemPrompt: "You are a coding assistant.",
},
}
config := &ProviderConfig{
ModelString: "custom/test-model",
}
ApplyModelSettings(config, modelInfo)
if config.SystemPrompt != "You are a coding assistant." {
t.Errorf("expected system prompt to be set, got %q", config.SystemPrompt)
}
})
t.Run("explicit system prompt takes precedence", func(t *testing.T) {
viper.Reset()
modelInfo := &ModelInfo{
ID: "test-model",
Params: &GenerationParams{
SystemPrompt: "Model-specific prompt",
},
}
config := &ProviderConfig{
ModelString: "custom/test-model",
SystemPrompt: "Global prompt",
}
ApplyModelSettings(config, modelInfo)
// Global system prompt should NOT be overridden because config
// already has a non-empty SystemPrompt.
if config.SystemPrompt != "Global prompt" {
t.Errorf("expected global prompt preserved, got %q", config.SystemPrompt)
}
})
t.Run("system prompt from file path", func(t *testing.T) {
viper.Reset()
// Create a temp file with a system prompt
tmpFile, err := os.CreateTemp("", "kit-test-prompt-*.txt")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.Remove(tmpFile.Name()) }()
if _, err := tmpFile.WriteString(" Prompt from file "); err != nil {
t.Fatal(err)
}
_ = tmpFile.Close()
modelInfo := &ModelInfo{
ID: "test-model",
Params: &GenerationParams{
SystemPrompt: tmpFile.Name(),
},
}
config := &ProviderConfig{
ModelString: "custom/test-model",
}
ApplyModelSettings(config, modelInfo)
if config.SystemPrompt != "Prompt from file" {
t.Errorf("expected trimmed file content, got %q", config.SystemPrompt)
}
})
t.Run("modelSettings system prompt overrides custom model params", func(t *testing.T) {
viper.Reset()
viper.Set("modelSettings", map[string]any{
"custom/test-model": map[string]any{
"systemPrompt": "From modelSettings",
},
})
modelInfo := &ModelInfo{
ID: "test-model",
Params: &GenerationParams{
SystemPrompt: "From custom model",
},
}
config := &ProviderConfig{
ModelString: "custom/test-model",
}
ApplyModelSettings(config, modelInfo)
if config.SystemPrompt != "From modelSettings" {
t.Errorf("expected modelSettings prompt, got %q", config.SystemPrompt)
}
})
}
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -48,10 +48,10 @@ type modelsDBLimit struct {
Output int `json:"output"`
}
// npmToLLMProvider maps npm package names from models.dev to LLM
// npmToFantasyProvider maps npm package names from models.dev to fantasy
// provider identifiers. Providers not in this map but with an api URL
// can be auto-routed through openaicompat.
var npmToLLMProvider = map[string]string{
var npmToFantasyProvider = map[string]string{
"@ai-sdk/anthropic": "anthropic",
"@ai-sdk/openai": "openai",
"@ai-sdk/google": "google",
+168
View File
@@ -0,0 +1,168 @@
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()
}
+56 -230
View File
@@ -22,9 +22,9 @@ import (
"charm.land/fantasy/providers/openaicompat"
"charm.land/fantasy/providers/openrouter"
"charm.land/fantasy/providers/vercel"
openaisdk "github.com/charmbracelet/openai-go"
"github.com/mark3labs/kit/internal/auth"
"github.com/mark3labs/kit/internal/ui/progress"
)
const (
@@ -85,7 +85,6 @@ type ThinkingLevel string
const (
ThinkingOff ThinkingLevel = "off"
ThinkingNone ThinkingLevel = "none"
ThinkingMinimal ThinkingLevel = "minimal"
ThinkingLow ThinkingLevel = "low"
ThinkingMedium ThinkingLevel = "medium"
@@ -94,14 +93,12 @@ const (
// ThinkingLevels returns the ordered list of available thinking levels for cycling.
func ThinkingLevels() []ThinkingLevel {
return []ThinkingLevel{ThinkingOff, ThinkingNone, ThinkingMinimal, ThinkingLow, ThinkingMedium, ThinkingHigh}
return []ThinkingLevel{ThinkingOff, ThinkingMinimal, ThinkingLow, ThinkingMedium, ThinkingHigh}
}
// thinkingBudgetTokens returns the token budget for a thinking level, or 0 for "off" or "none".
// thinkingBudgetTokens returns the token budget for a thinking level, or 0 for "off".
func thinkingBudgetTokens(level ThinkingLevel) int64 {
switch level {
case ThinkingNone:
return 1024
case ThinkingMinimal:
return 1024
case ThinkingLow:
@@ -120,8 +117,6 @@ 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:
@@ -138,7 +133,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 ThinkingNone, ThinkingMinimal, ThinkingLow, ThinkingMedium, ThinkingHigh:
case ThinkingMinimal, ThinkingLow, ThinkingMedium, ThinkingHigh:
return ThinkingLevel(s)
default:
return ThinkingOff
@@ -147,28 +142,19 @@ func ParseThinkingLevel(s string) ThinkingLevel {
// ProviderConfig holds configuration for creating LLM providers.
type ProviderConfig struct {
ModelString string
SystemPrompt string
ProviderAPIKey string
ProviderURL string
MaxTokens int
Temperature *float32
TopP *float32
TopK *int32
FrequencyPenalty *float32
PresencePenalty *float32
StopSequences []string
NumGPU *int32
MainGPU *int32
TLSSkipVerify bool
ThinkingLevel ThinkingLevel
DisableCaching bool // Opt-out: set to true to disable automatic prompt caching
// 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
// with no progress UI.
ProgressReaderFunc func(io.Reader) io.ReadCloser
ModelString string
SystemPrompt string
ProviderAPIKey string
ProviderURL string
MaxTokens int
Temperature *float32
TopP *float32
TopK *int32
StopSequences []string
NumGPU *int32
MainGPU *int32
TLSSkipVerify bool
ThinkingLevel ThinkingLevel
}
// ProviderResult contains the result of provider creation.
@@ -251,78 +237,30 @@ func CreateProvider(ctx context.Context, config *ProviderConfig) (*ProviderResul
validateModelConfig(config, modelInfo)
}
// Apply per-model generation parameter defaults. Model-level params are
// only applied for fields where the user hasn't explicitly set a value
// 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
switch provider {
case "anthropic":
result, createErr = createAnthropicProvider(ctx, config, modelName)
return createAnthropicProvider(ctx, config, modelName)
case "openai":
result, createErr = createOpenAIProvider(ctx, config, modelName)
return createOpenAIProvider(ctx, config, modelName)
case "google", "gemini":
result, createErr = createGoogleProvider(ctx, config, modelName)
return createGoogleProvider(ctx, config, modelName)
case "ollama":
result, createErr = createOllamaProvider(ctx, config, modelName)
return createOllamaProvider(ctx, config, modelName)
case "azure":
result, createErr = createAzureProvider(ctx, config, modelName)
return createAzureProvider(ctx, config, modelName)
case "google-vertex-anthropic":
result, createErr = createVertexAnthropicProvider(ctx, config, modelName)
return createVertexAnthropicProvider(ctx, config, modelName)
case "openrouter":
result, createErr = createOpenRouterProvider(ctx, config, modelName)
return createOpenRouterProvider(ctx, config, modelName)
case "bedrock":
result, createErr = createBedrockProvider(ctx, config, modelName)
return createBedrockProvider(ctx, config, modelName)
case "vercel":
result, createErr = createVercelProvider(ctx, config, modelName)
return createVercelProvider(ctx, config, modelName)
case "custom":
result, createErr = createCustomProvider(ctx, config, modelName)
return createCustomProvider(ctx, config, modelName)
default:
result, createErr = autoRouteProvider(ctx, config, provider, modelName, registry)
return autoRouteProvider(ctx, config, provider, modelName, registry)
}
if createErr != nil {
return nil, createErr
}
// AUTOMATICALLY ENABLE CACHING for supported models (unless disabled).
// This works for BOTH native and auto-routed providers by detecting
// the model family from the model metadata.
if cacheOpts := buildCacheProviderOptions(modelInfo, config); cacheOpts != nil {
if result.ProviderOptions == nil {
result.ProviderOptions = cacheOpts
} else {
// Merge cache options with existing provider options.
// 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 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
}
}
}
}
}
return result, nil
}
// autoRouteProvider attempts to create a provider by looking up its npm package
@@ -342,14 +280,14 @@ func autoRouteProvider(ctx context.Context, config *ProviderConfig, provider, mo
npmPackage = modelInfo.ProviderNPM
}
// Determine the LLM provider for this npm package
llmProvider := npmToLLMProvider[npmPackage]
if llmProvider == "" && providerInfo.API != "" {
// Determine the fantasy provider for this npm package
fantasyProvider := npmToFantasyProvider[npmPackage]
if fantasyProvider == "" && providerInfo.API != "" {
// Unknown npm but has API URL → route through openaicompat
llmProvider = "openaicompat"
fantasyProvider = "openaicompat"
}
switch llmProvider {
switch fantasyProvider {
case "openaicompat":
return createAutoRoutedOpenAICompatProvider(ctx, config, modelName, providerInfo)
case "anthropic":
@@ -363,7 +301,7 @@ func autoRouteProvider(ctx context.Context, config *ProviderConfig, provider, mo
}
return createAutoRoutedOpenAIProvider(ctx, config, modelName, providerInfo)
default:
return nil, fmt.Errorf("unsupported provider: %s (npm: %s has no LLM provider mapping)", provider, npmPackage)
return nil, fmt.Errorf("unsupported provider: %s (npm: %s has no fantasy mapping)", provider, npmPackage)
}
}
@@ -508,37 +446,6 @@ 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("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
@@ -585,85 +492,28 @@ 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)
return openai.ReasoningEffortOption(openai.ReasoningEffortMinimal)
case ThinkingLow:
return new(openai.ReasoningEffortLow)
return openai.ReasoningEffortOption(openai.ReasoningEffortLow)
case ThinkingMedium:
return new(openai.ReasoningEffortMedium)
return openai.ReasoningEffortOption(openai.ReasoningEffortMedium)
case ThinkingHigh:
return new(openai.ReasoningEffortHigh)
return openai.ReasoningEffortOption(openai.ReasoningEffortHigh)
default:
return nil
}
}
// 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
// or non-reasoning models the returned map is nil.
//
// NOTE: With message-level caching, thinking and caching can work together.
// Message-level cache control (ProviderCacheControlOptions) doesn't conflict
// with provider-level thinking options (ProviderOptions).
//
// Anthropic requires max_tokens > thinking.budget_tokens. If the configured
// MaxTokens is too low, it is bumped to budget + 4096 to leave room for the
// actual response.
func buildAnthropicProviderOptions(config *ProviderConfig, modelName string) fantasy.ProviderOptions {
// Thinking is OFF by default. If user hasn't explicitly enabled it, return nil.
if config.ThinkingLevel == "" || config.ThinkingLevel == ThinkingOff {
return nil
}
@@ -1113,29 +963,12 @@ func createVercelProvider(ctx context.Context, config *ProviderConfig, modelName
return &ProviderResult{Model: model}, nil
}
// customToPromptFunc converts prompts to OpenAI format using the default conversion.
func customToPromptFunc(prompt fantasy.Prompt, systemPrompt, user string) ([]openaisdk.ChatCompletionMessageParamUnion, []fantasy.CallWarning) {
return openai.DefaultToPrompt(prompt, systemPrompt, user)
}
func createCustomProvider(ctx context.Context, config *ProviderConfig, modelName string) (*ProviderResult, error) {
// Resolve base URL: per-model override > global provider-url flag/config
registry := GetGlobalRegistry()
modelInfo := registry.LookupModel("custom", modelName)
baseURL := config.ProviderURL
if modelInfo != nil && modelInfo.BaseURL != "" {
baseURL = modelInfo.BaseURL
}
if baseURL == "" {
return nil, fmt.Errorf("custom provider requires --provider-url or a baseUrl in the model config")
if config.ProviderURL == "" {
return nil, fmt.Errorf("custom provider requires --provider-url")
}
apiKey := config.ProviderAPIKey
if modelInfo != nil && modelInfo.APIKey != "" {
apiKey = modelInfo.APIKey
}
if apiKey == "" {
apiKey = os.Getenv("CUSTOM_API_KEY")
}
@@ -1144,21 +977,16 @@ func createCustomProvider(ctx context.Context, config *ProviderConfig, modelName
apiKey = "custom"
}
// <think> tag extraction is handled transparently at the agent layer,
// so no provider-level hooks are needed here.
var opts []openai.Option
opts = append(opts, openai.WithBaseURL(baseURL))
opts = append(opts, openai.WithAPIKey(apiKey))
opts = append(opts, openai.WithName("custom"))
opts = append(opts, openai.WithLanguageModelOptions(
openai.WithLanguageModelToPromptFunc(customToPromptFunc),
))
var opts []openaicompat.Option
opts = append(opts, openaicompat.WithBaseURL(config.ProviderURL))
opts = append(opts, openaicompat.WithAPIKey(apiKey))
opts = append(opts, openaicompat.WithName("custom"))
if config.TLSSkipVerify {
opts = append(opts, openai.WithHTTPClient(createHTTPClientWithTLSConfig(true)))
opts = append(opts, openaicompat.WithHTTPClient(createHTTPClientWithTLSConfig(true)))
}
p, err := openai.New(opts...)
p, err := openaicompat.New(opts...)
if err != nil {
return nil, fmt.Errorf("failed to create custom provider: %w", err)
}
@@ -1235,7 +1063,7 @@ func loadOllamaModelWithFallback(ctx context.Context, baseURL, modelName string,
// Phase 1: Check if model exists locally
if err := checkOllamaModelExists(client, baseURL, modelName); err != nil {
// Phase 2: Pull model if not found
if err := pullOllamaModel(ctx, client, baseURL, modelName, config.ProgressReaderFunc); err != nil {
if err := pullOllamaModel(ctx, client, baseURL, modelName); err != nil {
return nil, fmt.Errorf("failed to pull model %s: %v", modelName, err)
}
}
@@ -1278,12 +1106,6 @@ func buildOllamaOptions(config *ProviderConfig) map[string]any {
if config.TopK != nil {
options["top_k"] = int(*config.TopK)
}
if config.FrequencyPenalty != nil {
options["frequency_penalty"] = *config.FrequencyPenalty
}
if config.PresencePenalty != nil {
options["presence_penalty"] = *config.PresencePenalty
}
if len(config.StopSequences) > 0 {
options["stop"] = config.StopSequences
}
@@ -1324,7 +1146,11 @@ func checkOllamaModelExists(client *http.Client, baseURL, modelName string) erro
return nil
}
func pullOllamaModel(ctx context.Context, client *http.Client, baseURL, modelName string, progressFn func(io.Reader) io.ReadCloser) error {
func pullOllamaModel(ctx context.Context, client *http.Client, baseURL, modelName string) error {
return pullOllamaModelWithProgress(ctx, client, baseURL, modelName, true)
}
func pullOllamaModelWithProgress(ctx context.Context, client *http.Client, baseURL, modelName string, showProgress bool) error {
reqBody := map[string]string{"name": modelName}
jsonBody, _ := json.Marshal(reqBody)
@@ -1348,10 +1174,10 @@ func pullOllamaModel(ctx context.Context, client *http.Client, baseURL, modelNam
return fmt.Errorf("failed to pull model (status %d): %s", resp.StatusCode, string(body))
}
if progressFn != nil {
pr := progressFn(resp.Body)
defer func() { _ = pr.Close() }()
_, err = io.ReadAll(pr)
if showProgress {
progressReader := progress.NewProgressReader(resp.Body)
defer func() { _ = progressReader.Close() }()
_, err = io.ReadAll(progressReader)
} else {
_, err = io.ReadAll(resp.Body)
}
+15 -138
View File
@@ -4,7 +4,6 @@ import (
_ "embed"
"encoding/json"
"fmt"
"maps"
"os"
"strings"
@@ -18,58 +17,12 @@ var embeddedModelsJSON []byte
type ModelInfo struct {
ID string
Name string
Family string // Model family (e.g., "claude", "gpt", "gemini")
Attachment bool
Reasoning bool
Temperature bool
Cost Cost
Limit Limit
ProviderNPM string // Model-specific provider npm override (e.g. "@ai-sdk/anthropic")
BaseURL string // Per-model base URL override (custom models only)
APIKey string // Per-model API key override (custom models only)
// Params holds per-model generation parameter defaults. These are applied
// when the user hasn't explicitly set the corresponding CLI flag or global
// config value. Nil pointer fields mean "no model-level default".
Params *GenerationParams
}
// SupportsCaching returns true if this model family supports prompt caching.
// This enables automatic cost savings for supported models regardless of provider.
func (m *ModelInfo) SupportsCaching() bool {
switch {
case strings.HasPrefix(m.Family, "claude"):
return true
case strings.HasPrefix(m.Family, "gpt"),
strings.HasPrefix(m.Family, "o1"),
strings.HasPrefix(m.Family, "o3"),
strings.HasPrefix(m.Family, "o4"),
strings.HasPrefix(m.Family, "codex"):
return true
case strings.HasPrefix(m.Family, "gemini"):
return true
default:
return false
}
}
// CacheType returns the appropriate cache mechanism for this model family.
// Returns empty string if caching is not supported.
func (m *ModelInfo) CacheType() string {
switch {
case strings.HasPrefix(m.Family, "claude"):
return "anthropic-ephemeral"
case strings.HasPrefix(m.Family, "gpt"),
strings.HasPrefix(m.Family, "o1"),
strings.HasPrefix(m.Family, "o3"),
strings.HasPrefix(m.Family, "o4"),
strings.HasPrefix(m.Family, "codex"):
return "openai-prompt-cache"
case strings.HasPrefix(m.Family, "gemini"):
return "google-cached-content"
default:
return ""
}
}
// Cost represents the pricing information for a model.
@@ -112,30 +65,13 @@ func NewModelsRegistry() *ModelsRegistry {
}
// buildFromModelsDB converts models.dev provider data into our internal format.
// 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.
// It tries the on-disk cache first and falls back to the embedded database.
func buildFromModelsDB() map[string]ProviderInfo {
// 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
}
// Try cached data first (from `kit update-models`)
dbProviders, _ := LoadCachedProviders()
if len(dbProviders) == 0 {
// Fall back to compile-time embedded data
dbProviders = loadEmbeddedProviders()
}
providers := make(map[string]ProviderInfo, len(dbProviders))
@@ -150,7 +86,6 @@ func buildFromModelsDB() map[string]ProviderInfo {
modelsMap[modelID] = ModelInfo{
ID: dm.ID,
Name: dm.Name,
Family: dm.Family,
Attachment: dm.Attachment,
Reasoning: dm.Reasoning,
Temperature: dm.Temperature,
@@ -259,18 +194,6 @@ func (r *ModelsRegistry) LookupModel(provider, modelID string) *ModelInfo {
return &modelInfo
}
// LookupModelForSettings is a convenience function that parses a
// "provider/model" string and looks up the ModelInfo in the global registry.
// Returns nil when the model string is invalid or the model is unknown.
// Used by Kit.SetModel to pre-apply per-model settings before CreateProvider.
func LookupModelForSettings(modelString string) *ModelInfo {
provider, modelName, err := ParseModelString(modelString)
if err != nil {
return nil
}
return GetGlobalRegistry().LookupModel(provider, modelName)
}
// getRequiredEnvVars returns the required environment variables for a provider.
func (r *ModelsRegistry) getRequiredEnvVars(provider string) ([]string, error) {
providerInfo, exists := r.providers[provider]
@@ -385,27 +308,27 @@ func (r *ModelsRegistry) GetSupportedProviders() []string {
return providers
}
// GetLLMProviders returns provider IDs that have LLM support,
// GetFantasyProviders returns provider IDs that can be used with fantasy,
// either through a native provider or via openaicompat auto-routing.
func (r *ModelsRegistry) GetLLMProviders() []string {
func (r *ModelsRegistry) GetFantasyProviders() []string {
var providers []string
for providerID, info := range r.providers {
if isProviderLLMSupported(providerID, &info) {
if isProviderFantasySupported(providerID, &info) {
providers = append(providers, providerID)
}
}
return providers
}
// isProviderLLMSupported checks if a provider can be used with the LLM layer.
func isProviderLLMSupported(providerID string, info *ProviderInfo) bool {
// Ollama and custom are always supported (model names are user-defined).
if providerID == "ollama" || providerID == "custom" {
// isProviderFantasySupported checks if a provider can be used with fantasy.
func isProviderFantasySupported(providerID string, info *ProviderInfo) bool {
// Ollama is always supported (via openaicompat pointed at localhost)
if providerID == "ollama" {
return true
}
// Check if npm maps to an LLM provider
if _, ok := npmToLLMProvider[info.NPM]; ok {
// Check if npm maps to a fantasy provider
if _, ok := npmToFantasyProvider[info.NPM]; ok {
return true
}
@@ -432,52 +355,6 @@ func (r *ModelsRegistry) GetProviderInfo(provider string) *ProviderInfo {
return &info
}
// ValidateModelString checks whether a model string is well-formed and refers
// to a known provider. It returns a user-friendly error with suggestions when
// the model or provider is unrecognised. Passing validation does not guarantee
// that API authentication will succeed — it only catches obvious mistakes
// (typos, missing provider prefix, non-existent provider names) early so that
// callers such as subagent spawning can return fast feedback.
//
// Unknown models under a known provider are allowed (the provider API is the
// authority), but a completely unknown provider is rejected.
func (r *ModelsRegistry) ValidateModelString(modelString string) error {
provider, modelName, err := ParseModelString(modelString)
if err != nil {
return err
}
// Ollama and custom are always valid — model names are user-defined.
if provider == "ollama" || provider == "custom" {
return nil
}
// Check if the provider exists in the registry.
providerInfo := r.GetProviderInfo(provider)
if providerInfo == nil {
known := r.GetSupportedProviders()
return fmt.Errorf(
"unknown provider %q in model string %q. Known providers: %s",
provider, modelString, strings.Join(known, ", "),
)
}
// Provider exists — check if the model is known. An unknown model is
// only a warning (the provider API decides), but we surface suggestions
// so the caller can self-correct.
if r.LookupModel(provider, modelName) == nil {
if suggestions := r.SuggestModels(provider, modelName); len(suggestions) > 0 {
return fmt.Errorf(
"model %q not found for provider %s. Did you mean one of: %s",
modelName, provider, strings.Join(suggestions, ", "),
)
}
// No suggestions — let it through; the provider API is the authority.
}
return nil
}
// Global registry instance
var globalRegistry = NewModelsRegistry()
-92
View File
@@ -1,92 +0,0 @@
package models
import (
"strings"
"testing"
)
func TestValidateModelString(t *testing.T) {
registry := GetGlobalRegistry()
tests := []struct {
name string
model string
wantErr bool
errSubstr string // expected substring in error message (empty = don't check)
}{
{
name: "valid anthropic model",
model: "anthropic/claude-sonnet-4-6",
wantErr: false,
},
{
name: "missing provider prefix",
model: "claude-sonnet-4-6",
wantErr: true,
errSubstr: "invalid model format",
},
{
name: "empty string",
model: "",
wantErr: true,
errSubstr: "invalid model format",
},
{
name: "unknown provider",
model: "fakeprovider/some-model",
wantErr: true,
errSubstr: "unknown provider",
},
{
name: "ollama always valid",
model: "ollama/llama3",
wantErr: false,
},
{
name: "custom always valid",
model: "custom/my-fine-tune",
wantErr: false,
},
{
name: "empty provider",
model: "/claude-sonnet-4-6",
wantErr: true,
errSubstr: "invalid model format",
},
{
name: "empty model name",
model: "anthropic/",
wantErr: true,
errSubstr: "invalid model format",
},
{
name: "unknown model under known provider (no suggestions)",
model: "anthropic/totally-unknown-xyz-999",
wantErr: false, // no suggestions → passes through
},
{
name: "typo model under known provider with suggestions",
model: "anthropic/claude-sonet", // misspelled "sonnet"
wantErr: true,
errSubstr: "Did you mean",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := registry.ValidateModelString(tt.model)
if tt.wantErr && err == nil {
t.Errorf("ValidateModelString(%q) = nil, want error", tt.model)
}
if !tt.wantErr && err != nil {
t.Errorf("ValidateModelString(%q) = %v, want nil", tt.model, err)
}
if tt.errSubstr != "" && err != nil {
if !strings.Contains(err.Error(), tt.errSubstr) {
t.Errorf("ValidateModelString(%q) error = %q, want substring %q",
tt.model, err.Error(), tt.errSubstr)
}
}
})
}
}
-148
View File
@@ -1,148 +0,0 @@
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)
}
}

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