mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 11:40:13 +00:00
Compare commits
117 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f366eab84 | |||
| e8e99b19a8 | |||
| ef072f6e59 | |||
| 49f8b485be | |||
| febdc530e1 | |||
| e610bdd2d0 | |||
| 6100e8b3a8 | |||
| 9f125f3400 | |||
| 00eab47218 | |||
| 06bf6d087a | |||
| fd960921ca | |||
| 0b651a8df9 | |||
| 7315c1dea7 | |||
| 0313fa03ad | |||
| d27022bcfb | |||
| ae722d520f | |||
| 7a04bdfeba | |||
| 7e4708f511 | |||
| 1e12102b92 | |||
| ab2a77c95e | |||
| 1e78153b50 | |||
| a613361969 | |||
| 67722b0c24 | |||
| 1a2f6da40f | |||
| 747f5be099 | |||
| d7c4565999 | |||
| bd24f3315c | |||
| 592f8dc84f | |||
| 66c4a1eb15 | |||
| 5104477631 | |||
| 394a4676a1 | |||
| 30f2bc243d | |||
| 922e246098 | |||
| 32b6376515 | |||
| cf194ff89a | |||
| 03006425fa | |||
| a322dfc59a | |||
| b1387d837e | |||
| f561f4cfd9 | |||
| 64caed57d4 | |||
| 975c30a773 | |||
| 35b9360d64 | |||
| 1b8373e133 | |||
| 1a5e4ce7c5 | |||
| 8823977612 | |||
| 24e2ea111c | |||
| 31ea80ec4f | |||
| 99f2680c2e | |||
| da7e05eb87 | |||
| a95714a22d | |||
| c4a2b0f1a3 | |||
| 2016570e2d | |||
| d557f4b870 | |||
| 65054fe3db | |||
| 97d2246375 | |||
| 1e12505741 | |||
| 6755597c9b | |||
| 45689cb30d | |||
| 78570d4188 | |||
| 7cf38b37ee | |||
| 4ef57eec4e | |||
| cbd828e190 | |||
| d304805106 | |||
| 6e36053856 | |||
| 92eaaf6a59 | |||
| e6084b7bd0 | |||
| 34d5abff9c | |||
| fc0ddd5f4f | |||
| 7aa6160c75 | |||
| e830bf87ca | |||
| 3881d1c28f | |||
| 53f6682bd0 | |||
| 996b15c9b9 | |||
| aeb704367c | |||
| d2e23295b6 | |||
| e5a13e2e12 | |||
| 558fb5214f | |||
| 61408ed490 | |||
| 3cfb6437f9 | |||
| d33ad4028b | |||
| 307dcd1734 | |||
| 81240b075e | |||
| 9a662d440c | |||
| 4ba9d6fab3 | |||
| aec0e7cc01 | |||
| bac04636bf | |||
| 5f851fd08e | |||
| f8371836d8 | |||
| 74f00244be | |||
| b5d7fd4f3e | |||
| 5857d40978 | |||
| 3ff701054a | |||
| c1dee3ceba | |||
| 2d9783a44d | |||
| 88dd216e15 | |||
| 9e5806ade8 | |||
| 50f586ec8f | |||
| 8a8e684dff | |||
| 7ef99ac60f | |||
| a67f514560 | |||
| b6bb35cb71 | |||
| 4e82fac442 | |||
| 5ec2217b0f | |||
| 8a851723ba | |||
| 53b628c5f8 | |||
| e1c94cb362 | |||
| ecf95b52e1 | |||
| 0641c92acc | |||
| 3bb20f5283 | |||
| 633fa38b2b | |||
| f905cee48c | |||
| 182c10ea1a | |||
| fcaa52bf1c | |||
| 7e6455732c | |||
| 71301a9035 | |||
| 0974d37ab2 | |||
| 398e825df8 |
@@ -1,268 +0,0 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
const (
|
||||
diagnosticsTimeout = 20 * time.Second
|
||||
maxOutputBytes = 12_000
|
||||
)
|
||||
|
||||
type toolPathInput struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type lintResult struct {
|
||||
Output string
|
||||
Err error
|
||||
}
|
||||
|
||||
// Package-level state: set of .go files edited during the current agent turn.
|
||||
var editedFiles map[string]bool
|
||||
|
||||
func Init(api ext.API) {
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
ctx.Print("go-edit-lint extension loaded - will run gopls and golangci-lint after agent turns that edit Go files")
|
||||
})
|
||||
|
||||
// Track edited .go files — don't lint yet.
|
||||
api.OnToolResult(func(e ext.ToolResultEvent, ctx ext.Context) *ext.ToolResultResult {
|
||||
if e.IsError || !isEditOrWrite(e.ToolName) {
|
||||
return nil
|
||||
}
|
||||
|
||||
absPath, ok := resolveGoFilePath(e.Input, ctx.CWD)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if editedFiles == nil {
|
||||
editedFiles = make(map[string]bool)
|
||||
}
|
||||
editedFiles[absPath] = true
|
||||
return nil
|
||||
})
|
||||
|
||||
// After the agent turn ends, lint all collected files.
|
||||
api.OnAgentEnd(func(e ext.AgentEndEvent, ctx ext.Context) {
|
||||
if len(editedFiles) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Snapshot and reset immediately so the next turn starts clean.
|
||||
files := editedFiles
|
||||
editedFiles = nil
|
||||
|
||||
// Skip lint on errored turns.
|
||||
if e.StopReason == "error" {
|
||||
return
|
||||
}
|
||||
|
||||
// Collect unique directories and file list for gopls.
|
||||
var allGoplsOutput []string
|
||||
for absPath := range files {
|
||||
res := runGopls(ctx.CWD, absPath)
|
||||
formatted := formatToolResult(res, "")
|
||||
if formatted != "" {
|
||||
allGoplsOutput = append(allGoplsOutput, fmt.Sprintf("# %s\n%s", filepath.Base(absPath), formatted))
|
||||
}
|
||||
}
|
||||
|
||||
lintRes := runGolangCILint(ctx.CWD, "./...")
|
||||
|
||||
goplsSection := "No diagnostics."
|
||||
if len(allGoplsOutput) > 0 {
|
||||
goplsSection = strings.Join(allGoplsOutput, "\n\n")
|
||||
}
|
||||
lintSection := formatToolResult(lintRes, "No lint issues.")
|
||||
|
||||
// Build file list for the report header.
|
||||
var fileNames []string
|
||||
for absPath := range files {
|
||||
fileNames = append(fileNames, filepath.Base(absPath))
|
||||
}
|
||||
|
||||
report := fmt.Sprintf(
|
||||
"<go_diagnostics files=%q>\n[gopls]\n%s\n\n[golangci-lint]\n%s\n</go_diagnostics>",
|
||||
strings.Join(fileNames, ", "),
|
||||
goplsSection,
|
||||
lintSection,
|
||||
)
|
||||
|
||||
goplsIssues, lintIssues := countIssues(report)
|
||||
hasIssues := goplsIssues > 0 || lintIssues > 0
|
||||
|
||||
if hasIssues {
|
||||
// Show TUI block so the user sees it too.
|
||||
var msgLines []string
|
||||
msgLines = append(msgLines, fmt.Sprintf("Files: %s", strings.Join(fileNames, ", ")))
|
||||
if goplsIssues > 0 {
|
||||
msgLines = append(msgLines, fmt.Sprintf("gopls: %d issue(s)", goplsIssues))
|
||||
}
|
||||
if lintIssues > 0 {
|
||||
msgLines = append(msgLines, fmt.Sprintf("golangci-lint: %d issue(s)", lintIssues))
|
||||
}
|
||||
|
||||
borderColor := "#f9e2af" // yellow
|
||||
if goplsIssues > 0 && lintIssues > 0 {
|
||||
borderColor = "#f38ba8" // red
|
||||
}
|
||||
|
||||
ctx.PrintBlock(ext.PrintBlockOpts{
|
||||
Text: strings.Join(msgLines, "\n"),
|
||||
BorderColor: borderColor,
|
||||
Subtitle: "go-edit-lint",
|
||||
})
|
||||
|
||||
// Inject a follow-up message so the agent fixes the issues.
|
||||
ctx.SendMessage(report + "\n\n⚠️ DIAGNOSTICS FOUND: Please review and fix the issues above.")
|
||||
} else {
|
||||
ctx.PrintBlock(ext.PrintBlockOpts{
|
||||
Text: fmt.Sprintf("Files: %s\n✓ All clean", strings.Join(fileNames, ", ")),
|
||||
BorderColor: "#a6e3a1",
|
||||
Subtitle: "go-edit-lint",
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func isEditOrWrite(toolName string) bool {
|
||||
return strings.EqualFold(toolName, "edit") || strings.EqualFold(toolName, "write")
|
||||
}
|
||||
|
||||
func resolveGoFilePath(inputJSON, cwd string) (string, bool) {
|
||||
var args toolPathInput
|
||||
if err := json.Unmarshal([]byte(inputJSON), &args); err != nil || args.Path == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
absPath := args.Path
|
||||
if !filepath.IsAbs(absPath) {
|
||||
absPath = filepath.Join(cwd, absPath)
|
||||
}
|
||||
|
||||
if strings.ToLower(filepath.Ext(absPath)) != ".go" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return absPath, true
|
||||
}
|
||||
|
||||
func runGopls(cwd, absPath string) lintResult {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), diagnosticsTimeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "gopls", "check", absPath)
|
||||
cmd.Dir = cwd
|
||||
out, err := cmd.CombinedOutput()
|
||||
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return lintResult{Err: fmt.Errorf("timed out after %s", diagnosticsTimeout)}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return lintResult{Output: truncate(string(out), maxOutputBytes), Err: fmt.Errorf("failed to run gopls check: %w", err)}
|
||||
}
|
||||
|
||||
return lintResult{Output: truncate(string(out), maxOutputBytes)}
|
||||
}
|
||||
|
||||
func runGolangCILint(cwd, target string) lintResult {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), diagnosticsTimeout)
|
||||
defer cancel()
|
||||
|
||||
args := []string{
|
||||
"run",
|
||||
target,
|
||||
"--show-stats=false",
|
||||
"--output.text.path", "stdout",
|
||||
"--output.text.colors=false",
|
||||
"--output.text.print-issued-lines=false",
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "golangci-lint", args...)
|
||||
cmd.Dir = cwd
|
||||
out, err := cmd.CombinedOutput()
|
||||
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return lintResult{Err: fmt.Errorf("timed out after %s", diagnosticsTimeout)}
|
||||
}
|
||||
|
||||
trimmed := truncate(string(out), maxOutputBytes)
|
||||
if err == nil {
|
||||
return lintResult{Output: trimmed}
|
||||
}
|
||||
|
||||
exitErr, ok := err.(*exec.ExitError)
|
||||
if ok && exitErr.ExitCode() == 1 {
|
||||
return lintResult{Output: trimmed}
|
||||
}
|
||||
|
||||
return lintResult{Output: trimmed, Err: fmt.Errorf("failed to run golangci-lint: %w", err)}
|
||||
}
|
||||
|
||||
func formatToolResult(res lintResult, emptyFallback string) string {
|
||||
var lines []string
|
||||
if res.Err != nil {
|
||||
lines = append(lines, "ERROR: "+res.Err.Error())
|
||||
}
|
||||
out := strings.TrimSpace(res.Output)
|
||||
if out == "" {
|
||||
if res.Err == nil {
|
||||
if emptyFallback != "" {
|
||||
lines = append(lines, emptyFallback)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lines = append(lines, out)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return emptyFallback
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + "\n... output truncated ..."
|
||||
}
|
||||
|
||||
func countIssues(report string) (goplsCount, lintCount int) {
|
||||
goplsStart := strings.Index(report, "[gopls]")
|
||||
lintStart := strings.Index(report, "[golangci-lint]")
|
||||
endTag := strings.Index(report, "</go_diagnostics>")
|
||||
|
||||
if goplsStart != -1 && lintStart != -1 {
|
||||
goplsSection := report[goplsStart:lintStart]
|
||||
for _, line := range strings.Split(goplsSection, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" && line != "[gopls]" && line != "No diagnostics." && !strings.HasPrefix(line, "#") {
|
||||
goplsCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if lintStart != -1 && endTag != -1 {
|
||||
lintSection := report[lintStart:endTag]
|
||||
for _, line := range strings.Split(lintSection, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" && line != "[golangci-lint]" && line != "No lint issues." {
|
||||
lintCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return goplsCount, lintCount
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
---
|
||||
description: Read-only audit for dead code, duplication, boundary violations, and refactor opportunities
|
||||
---
|
||||
|
||||
Perform a comprehensive **read-only** audit of this repository and report
|
||||
findings. **Do not edit, rename, or delete any files.** Optional focus / scope
|
||||
hints from the user: $@
|
||||
|
||||
## Scope
|
||||
|
||||
If the user supplied focus hints above (a package path, a subsystem name, a
|
||||
concern like "TUI" or "extensions"), scope the audit accordingly. Otherwise
|
||||
audit the whole repo, prioritising the highest-traffic packages first
|
||||
(`cmd/`, `internal/`, `pkg/kit/` for this repo).
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Map the repo first**:
|
||||
- `ls` / `find` the top-level layout and list every Go package
|
||||
- Read `AGENTS.md`, `README.md`, and any `pkg/*/doc.go` to understand the
|
||||
intended architectural boundaries (SDK vs internal vs TUI vs cmd vs
|
||||
extension surface)
|
||||
- Note the public SDK surface (`pkg/kit/`) and any documented invariants
|
||||
(e.g. "no dependency name leakage", "UI never imports extensions
|
||||
directly") — these define what counts as a violation
|
||||
|
||||
2. **Hunt for dead code**:
|
||||
- Run `go vet ./...` and capture warnings
|
||||
- Use `grep` to find exported symbols (`^func [A-Z]`, `^type [A-Z]`,
|
||||
`^var [A-Z]`, `^const [A-Z]`) and cross-reference call sites. Symbols
|
||||
with zero non-test references inside the module are suspects
|
||||
- Check for unreferenced files, `// TODO: remove` markers, commented-out
|
||||
blocks, and `_ = x` discard patterns
|
||||
- If `staticcheck`, `deadcode`, or `unused` are available on PATH, run
|
||||
them and include their output verbatim
|
||||
- **Do not delete anything** — list candidates with file:line and a
|
||||
confidence level (high / medium / low)
|
||||
|
||||
3. **Find unnecessary duplication**:
|
||||
- Look for near-identical function bodies, struct shapes, or switch
|
||||
statements across packages — `grep` for repeated function signatures
|
||||
and copy-pasted string literals / error messages is a fast first pass
|
||||
- Distinguish *coincidental* duplication (two things that happen to look
|
||||
alike but evolve independently) from *unnecessary* duplication (same
|
||||
intent, drifting in lockstep) — only flag the latter
|
||||
- For each cluster, propose where the extracted helper should live
|
||||
(which package, which file) and whether it crosses a boundary
|
||||
|
||||
4. **Check concerns / boundary violations**:
|
||||
- **SDK leakage**: grep `pkg/kit/` for imports of `internal/...` types
|
||||
in exported signatures, and for dependency-name leakage in exported
|
||||
names / godoc (e.g. library jargon appearing in `LLM*` types)
|
||||
- **UI ↔ extensions**: grep `internal/ui/` for any import of
|
||||
`internal/extensions/` — per AGENTS.md the UI must not import
|
||||
extensions directly; converters in `cmd/root.go` should bridge them
|
||||
- **cmd vs internal**: business logic living in `cmd/` that should be
|
||||
in `internal/` (and vice versa)
|
||||
- **Cyclic risk**: packages that import each other transitively or that
|
||||
reach across sibling boundaries unexpectedly
|
||||
- For each violation, cite the offending import / signature with
|
||||
file:line
|
||||
|
||||
5. **Spot refactor opportunities**:
|
||||
- Long functions (>80 lines) doing multiple unrelated things
|
||||
- Deeply nested conditionals that flatten well with early returns
|
||||
- Repeated `if err != nil { return fmt.Errorf("...: %w", err) }` chains
|
||||
that could become helpers — but only where the wrapping context is
|
||||
genuinely uniform
|
||||
- Structs with too many fields that hint at split responsibilities
|
||||
- Exported APIs that would be cleaner with options structs / functional
|
||||
options
|
||||
- Tests that share setup boilerplate ripe for a helper
|
||||
- Flag each with: location, current shape (1-2 lines), proposed shape
|
||||
(1-2 lines), and estimated risk (low / medium / high)
|
||||
|
||||
6. **Cross-check against project rules**:
|
||||
- Re-read `AGENTS.md` "Key Patterns" section and verify nothing in your
|
||||
findings contradicts the documented gotchas (Yaegi interface ban,
|
||||
`prog.Send()` from `Update()`, function-field bug, etc.) — if a
|
||||
"refactor" would reintroduce a known pitfall, drop it from the report
|
||||
and note why
|
||||
|
||||
7. **Write the report** as your final message (do not write it to disk)
|
||||
structured as:
|
||||
|
||||
```
|
||||
# Code Audit Report
|
||||
|
||||
## Summary
|
||||
- N dead-code candidates
|
||||
- N duplication clusters
|
||||
- N boundary violations
|
||||
- N refactor opportunities
|
||||
|
||||
## Dead Code
|
||||
### High confidence
|
||||
- path/to/file.go:LINE — symbol — reason
|
||||
|
||||
### Medium confidence
|
||||
...
|
||||
|
||||
## Duplication
|
||||
### Cluster: <short name>
|
||||
- Sites: file:line, file:line, …
|
||||
- Suggested home: package/path
|
||||
- Notes: …
|
||||
|
||||
## Boundary Violations
|
||||
- Rule: <which rule from AGENTS.md / project convention>
|
||||
- Offender: file:line
|
||||
- Fix sketch: …
|
||||
|
||||
## Refactor Opportunities
|
||||
- Location: file:line
|
||||
- Current: …
|
||||
- Proposed: …
|
||||
- Risk: low/medium/high
|
||||
- Why it's worth it: …
|
||||
|
||||
## Suggested Next Steps
|
||||
1. …
|
||||
2. …
|
||||
```
|
||||
|
||||
8. **End the report with an explicit reminder** that no files were modified,
|
||||
and recommend the user pick the highest-leverage items to act on
|
||||
manually (or via a follow-up `/fix-issue` style prompt) rather than
|
||||
running a sweeping refactor.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- **Read-only, always**: no `edit`, no `write`, no `git commit`, no `go mod
|
||||
tidy`. Use only `read`, `grep`, `find`, `ls`, and read-only `bash`
|
||||
commands (`go vet`, `go build -o /tmp/...`, `staticcheck`, etc.)
|
||||
- **Cite every finding** with `path/to/file.go:LINE` so the user can jump
|
||||
straight to it
|
||||
- **Be honest about confidence**: false positives in a code audit are
|
||||
expensive — prefer "medium confidence, worth a look" over confidently
|
||||
wrong claims
|
||||
- **Quantity isn't quality**: 10 sharp findings beat 100 nitpicks. Cut
|
||||
anything that's purely stylistic unless it directly causes one of the
|
||||
four issue categories above
|
||||
- **Skip generated code** (`*.pb.go`, `*_gen.go`, anything under
|
||||
`vendor/`) and obvious third-party copies
|
||||
- **Don't propose architectural rewrites** — stay within the existing
|
||||
shape of the repo and recommend incremental, reviewable changes
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
description: Open a GitHub PR for the current branch using the repo's PR template
|
||||
---
|
||||
|
||||
Open a GitHub pull request for the current branch, filling out the repository's PR template with a description grounded in the actual commits and diff.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Verify the branch is pushed**:
|
||||
- `git status -sb` and `git log @{u}..HEAD --oneline 2>/dev/null` — if there is no upstream or unpushed commits, run `git push -u origin "$(git branch --show-current)"` first
|
||||
- If the working tree is dirty, stop and tell the user to commit first (suggest `/commit-push`)
|
||||
2. **Gather context**:
|
||||
- `git log origin/main..HEAD --oneline` — list of commits going into the PR
|
||||
- `git diff origin/main...HEAD --stat` then `git diff origin/main...HEAD` — read the actual changes
|
||||
- Identify the linked issue (from commit messages, branch name, or extra user input: $@) — capture as `Fixes #N` if applicable
|
||||
3. **Locate the PR template**:
|
||||
- Check `.github/pull_request_template.md`, `.github/PULL_REQUEST_TEMPLATE.md`, or `docs/pull_request_template.md`
|
||||
- If none exists, use a minimal `## Description` / `## Type of Change` / `## Checklist` structure
|
||||
4. **Draft the PR body** by filling out the template:
|
||||
- **Description**: 1–3 short paragraphs explaining *what* changed and *why*, grounded in the diff. Include a brief before/after example for new APIs when useful.
|
||||
- **Fixes #N**: only if there is a real linked issue
|
||||
- **Type of Change**: tick the single most accurate box with `[x]` (leave others as `[ ]`)
|
||||
- **Checklist**: tick items that are genuinely true (style, self-review, tests added, docs updated)
|
||||
- **Additional Information**: bullet list of added / modified files and any backward-compatibility notes
|
||||
- Remove template sections explicitly marked "remove if not applicable" (e.g. MCP Spec Compliance) when they don't apply
|
||||
5. **Write the body to a temp file**: `/tmp/pr-body-<branch-or-issue>.md` — never inline a long body via `--body`, always use `--body-file`
|
||||
6. **Choose the title**: prefer the subject of the primary commit if it already follows Conventional Commits; otherwise craft one in the same style (`<type>(<scope>): <imperative summary>`, ≤72 chars)
|
||||
7. **Create the PR**:
|
||||
```
|
||||
gh pr create \
|
||||
--title "<title>" \
|
||||
--body-file /tmp/pr-body-<...>.md \
|
||||
--base main \
|
||||
--head "$(git branch --show-current)"
|
||||
```
|
||||
Use the repo's actual default branch if it isn't `main` (`gh repo view --json defaultBranchRef -q .defaultBranchRef.name`)
|
||||
8. **Report the PR URL** returned by `gh` and stop
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Read the diff and commit messages — do **not** invent features that aren't in the code
|
||||
- One PR per logical change; if the branch contains unrelated commits, surface that and ask before continuing
|
||||
- Keep the description focused on reviewer-relevant information (what / why), not a replay of the diff
|
||||
- Only check checklist boxes that are actually satisfied; leave the rest unchecked rather than lying
|
||||
- If `gh` is not authenticated (`gh auth status` fails), stop and tell the user
|
||||
|
||||
$@
|
||||
@@ -2,7 +2,7 @@
|
||||
description: Create a feature request using the GitHub template
|
||||
---
|
||||
|
||||
Create a feature request for the Kit repository. The user wants to request: $+
|
||||
Create a feature request for the Kit repository. The user wants to request: $@
|
||||
|
||||
## Feature Request Template
|
||||
|
||||
@@ -16,7 +16,7 @@ This prompt uses the `feature_request` GitHub template which requires:
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Understand the request** from `$+`
|
||||
1. **Understand the request** from the user input: $@
|
||||
- What capability is missing?
|
||||
- What would the ideal behavior look like?
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
description: File a GitHub issue using the appropriate template
|
||||
---
|
||||
|
||||
File a GitHub issue for the Kit repository. The user wants to create an issue about: $+
|
||||
File a GitHub issue for the Kit repository. The user wants to create an issue about: $@
|
||||
|
||||
## Issue Templates Available
|
||||
|
||||
@@ -16,7 +16,7 @@ This repository has structured issue templates. You MUST use the appropriate tem
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Determine the issue type** from `$+`:
|
||||
1. **Determine the issue type** from the user input: $@
|
||||
- Bug → use `--template bug_report`
|
||||
- Feature → use `--template feature_request`
|
||||
- Documentation → use `--template documentation`
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
description: Implement the fix/feature/docs change requested by a GitHub issue
|
||||
---
|
||||
|
||||
Resolve GitHub issue #$1 by reading it, classifying it, and producing the appropriate code or doc change. **Stop once the working tree contains the change** — committing, pushing, and opening a PR are handled by `/commit-push` and `/create-pr`.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Fetch the issue**:
|
||||
- Run: gh issue view $1 --json number,title,body,labels,state,author,comments
|
||||
- If the issue is closed, stop and ask the user whether to proceed
|
||||
- Read the **entire** thread including comments — the latest comment often refines the ask
|
||||
|
||||
2. **Classify the issue** from labels, title prefix, and body content:
|
||||
- `bug` / `fix:` → reproduce, then fix
|
||||
- `enhancement` / `feature` / `feat:` → design, then implement
|
||||
- `documentation` / `docs:` → locate and update docs
|
||||
- `question` / `discussion` → answer in a comment, do **not** write code
|
||||
- Anything else → ask the user how to proceed
|
||||
|
||||
3. **Create a working branch** off the default branch:
|
||||
- `git checkout main && git pull --ff-only`
|
||||
- Branch name: <type>/$1-<slug> (e.g. `fix/42-borderColor-ignored`, `feat/57-keyboard-clear`, `docs/63-widget-lifecycle`)
|
||||
|
||||
4. **Do the work** based on type:
|
||||
|
||||
### Bug (`bug` label / `fix:` title)
|
||||
- Reproduce the failure first (write a failing test if feasible) — if you cannot reproduce, comment on the issue asking for clarification and stop
|
||||
- Locate the root cause; do not patch symptoms
|
||||
- Add or extend a regression test that fails before and passes after the fix
|
||||
- Run `go test ./... -race` and `golangci-lint run`
|
||||
|
||||
### Feature (`enhancement` / `feature` label / `feat:` title)
|
||||
- Re-read the motivation and proposed implementation in the issue body
|
||||
- For large, ambiguous, or breaking changes, sketch the design in a comment on the issue and wait for sign-off before writing code
|
||||
- Implement behind sensible defaults; add godoc on every exported symbol
|
||||
- Add unit tests covering the new behaviour and edge cases
|
||||
- Update `README.md` / `docs/` if the public surface changed
|
||||
- Run `go test ./... -race` and `golangci-lint run`
|
||||
|
||||
### Documentation (`documentation` label / `docs:` title)
|
||||
- Open the file/URL referenced in the issue's "Documentation Location"
|
||||
- Apply the suggested improvement; verify code samples compile (`go build ./...`)
|
||||
- No tests required, but run `golangci-lint run` if Go files were touched
|
||||
|
||||
5. **Report**:
|
||||
- Branch name (`git branch --show-current`)
|
||||
- Summary of files changed (`git status -s`) and the diff highlights
|
||||
- Test/lint results (pass/fail with key output)
|
||||
- Suggest the next step explicitly:
|
||||
- `/commit-push` to commit with a Conventional Commit subject (the message should reference `(#$1)` and include `Fixes #$1` so merge auto-closes)
|
||||
- then `/create-pr $1` to open the pull request
|
||||
|
||||
## Guidelines
|
||||
|
||||
- This prompt **stops at a clean working tree with the change applied** — do not run `git commit`, `git push`, or `gh pr create`
|
||||
- If the issue is unclear, post a clarifying comment on the issue and stop; do not guess
|
||||
- Keep the change scoped to the issue; surface unrelated cleanups separately
|
||||
- For breaking changes or architecture shifts, propose the design on the issue first and wait for maintainer sign-off
|
||||
- If the issue is a duplicate or already fixed on `main`, comment with the reference and stop
|
||||
- Do not close the issue manually — the eventual PR's `Fixes #$1` handles that on merge
|
||||
+48
-13
@@ -2,7 +2,7 @@
|
||||
description: Scaffold a new prompt template in .kit/prompts/
|
||||
---
|
||||
|
||||
Create a new kit prompt template. The user wants a prompt that does: $+
|
||||
Create a new kit prompt template. The user wants a prompt that does: $@
|
||||
|
||||
## What a prompt template is
|
||||
|
||||
@@ -16,30 +16,64 @@ It becomes a `/slug` slash command in the kit input box — typed as `/filename`
|
||||
description: One-line description shown in autocomplete
|
||||
---
|
||||
|
||||
Body text of the prompt. Use $@ for all user-supplied arguments,
|
||||
$1 $2 etc. for positional arguments.
|
||||
Body text of the prompt. Reference user-supplied arguments
|
||||
with positional placeholders (see "Argument placeholders" below).
|
||||
```
|
||||
|
||||
- **Filename** → slug: `commit-push.md` becomes `/commit-push`
|
||||
- **Frontmatter**: only `description` is recognised; keep it under ~80 chars
|
||||
- **Body**: plain markdown; the full text is submitted as the user's message when the template fires
|
||||
- **Arguments**: `$+` expands to everything the user typed after the slash command name
|
||||
(requires at least one argument); `$@` is the same but allows zero arguments;
|
||||
`$1`, `$2` for individual positional args; omit entirely if no arguments are needed
|
||||
- **Required args**: kit infers required positional args from the highest `$N` it finds *outside* backtick/tilde code fences — a stray `$2` in active prose means kit will refuse to run without 2 arguments
|
||||
|
||||
## Argument placeholders
|
||||
|
||||
kit performs shell-style substitution before sending the prompt to the model:
|
||||
|
||||
- `$1`, `$2`, … — positional arguments (1-indexed)
|
||||
- `${1}`, `${2}`, … — same, brace form (use when followed by digits/letters: `${1}_suffix`)
|
||||
- `$@` — all arguments joined by spaces (zero or more, optional)
|
||||
- `$+` — all arguments, **at least one required**
|
||||
- `$ARGUMENTS` / `${ARGUMENTS}` — alias for `$@`
|
||||
- `${@:N}` — args from the Nth onwards (1-indexed, bash-style)
|
||||
- `${@:N:L}` — `L` args starting from the Nth
|
||||
|
||||
### ⚠️ Critical: code fences and inline code preserve placeholders verbatim
|
||||
|
||||
Anything inside triple-backtick fences, `~~~` fences, or single-backtick `inline` code spans is **left untouched** so example code samples don't get corrupted. That means:
|
||||
|
||||
- An inline-coded `gh issue view $1` stays literal `$1` in the model's input ❌
|
||||
- The same command without backticks: gh issue view $1 → expands to `gh issue view 42` ✓
|
||||
|
||||
**Rule of thumb:** if you want a placeholder to substitute, keep it outside backticks and fences. If you want a literal `$1` in the output (e.g. teaching the user shell syntax), put it inside backticks.
|
||||
|
||||
### Workarounds for "I want it to look like code AND substitute"
|
||||
|
||||
1. **Drop the backticks** around just the placeholder portion — the rest can still read as a command line in prose
|
||||
2. **Use a 4-space-indented code block** instead of a triple-backtick fence — kit only skips backtick/tilde fences, so indentation-style code blocks still get substitution:
|
||||
|
||||
git push -u origin "$(git branch --show-current)"
|
||||
gh pr create --title "fix: ... (#$1)" --base main
|
||||
|
||||
3. **Bind once, reference loosely**: put `Issue: $1` at the top in prose, then leave the backticked examples literal — the model will substitute mentally
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Understand the workflow** the user described in `$+` — ask a clarifying question if the intent is ambiguous
|
||||
1. **Understand the workflow** the user described in $@ — ask a clarifying question if the intent is ambiguous
|
||||
2. **Choose a filename**: short, lowercase, hyphen-separated, descriptive (e.g. `code-review.md`)
|
||||
3. **Write the description**: one sentence, imperative, fits in autocomplete
|
||||
4. **Draft the body**:
|
||||
- Open with a single sentence stating the goal
|
||||
4. **Decide on arguments**:
|
||||
- No args needed → omit placeholders entirely
|
||||
- One required value (issue number, PR url, file path) → use `$1`
|
||||
- Free-form trailing context → end with a single `$@` line
|
||||
- Multiple distinct values → use `$1`, `$2`, … and document each at the top
|
||||
5. **Draft the body**:
|
||||
- Open with a single sentence stating the goal, weaving in `$1`/`$@` where the value belongs
|
||||
- Use `## Steps` for multi-step workflows; use plain prose for simple prompts
|
||||
- Be specific: name commands, flags, and file paths where relevant
|
||||
- End with `$+` on its own line if the user must pass context; use `$@` if arguments
|
||||
are optional; omit if the prompt is self-contained
|
||||
5. **Write the file** to `.kit/prompts/<slug>.md`
|
||||
6. **Confirm** by showing the final file content and the slash command that activates it
|
||||
- **Audit every backtick and code fence**: any `$N` or `$@` inside them will not expand — was that intentional? If not, apply one of the workarounds above
|
||||
6. **Write the file** to `.kit/prompts/<slug>.md`
|
||||
7. **Verify substitution** by mentally (or actually) replacing `$1`/`$@` with a sample value and confirming every reference resolves — and that the prompt's *own* example snippets don't accidentally bump the required-arg count (wrap illustrative `$N` examples in triple-backtick fences, not 4-space indentation, so `RequiredArgs()` ignores them)
|
||||
8. **Confirm** by showing the final file content and the slash command that activates it (e.g. `/code-review 42`)
|
||||
|
||||
## Guidelines
|
||||
|
||||
@@ -47,3 +81,4 @@ $1 $2 etc. for positional arguments.
|
||||
- Prefer concrete steps over vague instructions
|
||||
- A prompt that does one thing well beats one that tries to cover every edge case
|
||||
- If the workflow already exists as a prompt, suggest extending it instead of duplicating
|
||||
- When in doubt about substitution behaviour, write the file and run `/<slug> testvalue` once to confirm — wrong placement of backticks is the #1 failure mode
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
description: Audit and update project documentation (README and docs site) for a recent change
|
||||
---
|
||||
|
||||
Review recent code changes, identify all documentation surfaces that should
|
||||
mention them, and update each one — grounded in the actual diff, not guesses.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Identify the change**:
|
||||
- If the user input ($@) names a commit / PR / branch / topic, use that as the focus
|
||||
- Otherwise inspect `git log origin/main..HEAD --oneline` and `git diff origin/main...HEAD --stat` to discover what shipped on the current branch
|
||||
- Read the actual diff (`git diff origin/main...HEAD`) — never document features that aren't in the code
|
||||
|
||||
2. **Inventory the doc surfaces**:
|
||||
- `README.md` at the repo root
|
||||
- Any docs site (commonly `www/`, `docs/`, `site/`) — list its pages and identify the one(s) most thematically related to the change
|
||||
- Inline godoc / API reference comments on the new exported symbols
|
||||
- `CHANGELOG.md` if the project keeps one
|
||||
- Any `examples/` directory entries that demonstrate the affected area
|
||||
|
||||
3. **Audit each surface** with `grep`:
|
||||
- Search for the names of related existing APIs (e.g. if you added `IterTools`, grep for `ListTools`) to find every page that already discusses the area
|
||||
- Decide for each hit: does it need a cross-reference, a side-by-side comparison, or to stay untouched?
|
||||
|
||||
4. **Decide where new content lives**:
|
||||
- Prefer extending an existing page over creating a new one
|
||||
- For a docs site, place new sections near related content (check the page's `## Heading` outline first)
|
||||
- Skip surfaces that genuinely don't apply (e.g. a server-focused README for a client-only change) and say so explicitly
|
||||
|
||||
5. **Draft the updates**:
|
||||
- Lead with a one-sentence statement of what's new and why
|
||||
- Show concrete code examples copied from real signatures — verify against the source files
|
||||
- Include a comparison / "when to use which" table when adding an alternative to an existing API
|
||||
- Note backwards-compatibility behaviour if relevant
|
||||
|
||||
6. **Verify the docs build** before committing:
|
||||
- For vocs / docusaurus / mkdocs sites, run the local build command (e.g. `npx vocs build`, `mkdocs build`) and fix any MDX/markdown errors
|
||||
- For godoc, run `go vet ./...` and `go doc <pkg> <Symbol>` to sanity-check rendering
|
||||
|
||||
7. **Report**:
|
||||
- List every file changed and every file deliberately left alone (with a one-line reason)
|
||||
- Suggest the next step (typically `/commit-push`) — do not auto-commit unless asked
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Read the diff before writing anything — invented API names erode trust faster than missing docs
|
||||
- One change per doc commit; keep doc updates separate from code changes when possible
|
||||
- Match the existing voice and formatting of each surface (headings, code-fence languages, table styles)
|
||||
- Prefer linking between pages over duplicating content
|
||||
|
||||
$@
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"permission": {
|
||||
"external_directory": {
|
||||
"~/go/**": "deny"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
# Autoscroll Fix - Final Summary
|
||||
|
||||
## Root Cause
|
||||
|
||||
The autoscroll was failing for streaming assistant messages due to a bug in how `GotoBottom()` calculated item heights.
|
||||
|
||||
### The Problem
|
||||
|
||||
1. **Reasoning blocks** (`StreamingMessageItem` with `role="reasoning"`) are **never cached** because they have live duration counters that update every render
|
||||
2. The `Height()` method returns `0` when `cachedRender == ""`
|
||||
3. `GotoBottom()` was calling:
|
||||
```go
|
||||
itemHeight := item.Height() // Returns 0 for reasoning
|
||||
if itemHeight == 0 {
|
||||
item.Render(s.width) // Renders but doesn't cache (reasoning)
|
||||
itemHeight = item.Height() // Still returns 0!
|
||||
}
|
||||
```
|
||||
4. This caused incorrect scroll position calculations, especially during reasoning → assistant transitions
|
||||
|
||||
## The Solution
|
||||
|
||||
Changed `GotoBottom()` and `AtBottom()` to calculate height **directly from the rendered string** instead of relying on the cached height:
|
||||
|
||||
```go
|
||||
// OLD: item.Height() which checks cached render
|
||||
itemHeight := item.Height()
|
||||
if itemHeight == 0 {
|
||||
item.Render(s.width)
|
||||
itemHeight = item.Height() // Still might be 0!
|
||||
}
|
||||
|
||||
// NEW: Calculate from rendered string directly
|
||||
rendered := item.Render(s.width)
|
||||
itemHeight := strings.Count(rendered, "\n") + 1
|
||||
```
|
||||
|
||||
This works for **all** items regardless of whether they cache their render or not.
|
||||
|
||||
## Files Changed
|
||||
|
||||
### `internal/ui/scrolllist.go`
|
||||
- **`GotoBottom()`**: Calculate height from rendered string (2 loops)
|
||||
- **`AtBottom()`**: Calculate height from rendered string (1 loop)
|
||||
|
||||
### `internal/ui/model.go`
|
||||
- **`appendStreamingChunk()`**: For existing messages, call `GotoBottom()` directly (iteratr pattern)
|
||||
- **`refreshContent()`**: Simplified to only call `SetItems()` (removed redundant `GotoBottom()`)
|
||||
- **Bash streaming handler**: Removed redundant `GotoBottom()` after `refreshContent()`
|
||||
|
||||
## Testing Results
|
||||
|
||||
✅ **Test prompt**: "explore this repo"
|
||||
|
||||
**Before fix**:
|
||||
- Autoscroll stopped after reasoning block completed
|
||||
- Viewport stuck showing end of reasoning ("Thought for 203ms")
|
||||
- Assistant response streamed off-screen below
|
||||
|
||||
**After fix**:
|
||||
- Autoscroll works throughout reasoning block
|
||||
- Autoscroll continues during reasoning → assistant transition
|
||||
- Viewport stays at bottom showing latest assistant content
|
||||
- Final position shows end of response (build commands section)
|
||||
|
||||
## Behavior Verified
|
||||
|
||||
1. ✅ Streaming text auto-scrolls to bottom
|
||||
2. ✅ Works across reasoning → assistant transition
|
||||
3. ✅ Manual scroll up (PgUp) disables autoscroll
|
||||
4. ✅ Scroll to bottom (Alt+End) re-enables autoscroll
|
||||
5. ✅ Accurate positioning with no offset errors
|
||||
|
||||
## Performance Note
|
||||
|
||||
The fix calls `Render()` on all items during `GotoBottom()` calculations. This is acceptable because:
|
||||
- `Render()` is already optimized with caching for non-reasoning items
|
||||
- `GotoBottom()` is only called during content updates (not every frame)
|
||||
- Reasoning blocks need to render anyway for live duration updates
|
||||
- This matches iteratr's approach of ensuring items are rendered before height calculations
|
||||
@@ -18,7 +18,8 @@ A powerful, extensible AI coding agent CLI with multi-provider support, built-in
|
||||
## Features
|
||||
|
||||
- **Multi-Provider LLM Support**: Anthropic, OpenAI, Google Gemini, Ollama, Azure OpenAI, AWS Bedrock, OpenRouter, and more
|
||||
- **Built-in Core Tools**: bash, read, write, edit, grep, find, ls, subagent - no MCP overhead
|
||||
- **Built-in Core Tools**: bash (with interactive sudo password prompt), read, write, edit, grep, find, ls, subagent - no MCP overhead
|
||||
- **Smart @ Attachments**: Binary files auto-detected via MIME type, MCP resources via `@mcp:server:uri`
|
||||
- **MCP Integration**: Connect external MCP servers for expanded capabilities
|
||||
- **Extension System**: Write custom tools, commands, widgets, and UI modifications in Go
|
||||
- **Theming**: 22 built-in color themes (KITT, Catppuccin, Dracula, Nord, etc.) with runtime switching, persistence, and custom theme files
|
||||
@@ -28,7 +29,7 @@ A powerful, extensible AI coding agent CLI with multi-provider support, built-in
|
||||
- **Session Management**: Tree-based conversation history with branching support
|
||||
- **Non-Interactive Mode**: Script-friendly positional args with JSON output
|
||||
- **ACP Server**: Run Kit as an [Agent Client Protocol](https://agentclientprotocol.com) agent over stdio
|
||||
- **Go SDK**: Embed Kit in your own applications
|
||||
- **Go SDK**: Embed Kit in your own applications with full agent lifecycle events (30+ event types) and behavior-modifying hooks
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -125,8 +126,20 @@ model: anthropic/claude-sonnet-latest
|
||||
max-tokens: 4096
|
||||
temperature: 0.7
|
||||
stream: true
|
||||
thinking-level: off # off, none, minimal, low, medium, high
|
||||
no-core-tools: false # set to true to disable all built-in core tools
|
||||
|
||||
# Skills — all three keys are optional
|
||||
no-skills: false # set to true to disable all skill loading
|
||||
skill: # explicit skill files/dirs (disables auto-discovery)
|
||||
- /path/to/skill.md
|
||||
skills-dir: "" # override project-local directory for auto-discovery
|
||||
```
|
||||
|
||||
All of the above keys can also be set programmatically via the SDK
|
||||
(`kit.Options.MaxTokens`, `Options.Temperature`, `Options.ThinkingLevel`, etc.)
|
||||
without touching config files — see [SDK options](#with-options).
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
@@ -151,6 +164,16 @@ mcpServers:
|
||||
search:
|
||||
type: remote
|
||||
url: "https://mcp.example.com/search"
|
||||
|
||||
pubmed:
|
||||
type: remote
|
||||
url: "https://pubmed.mcp.example.com"
|
||||
noOAuth: true # skip OAuth for public servers that don't require auth
|
||||
|
||||
builds:
|
||||
type: remote
|
||||
url: "https://builds.mcp.example.com"
|
||||
tasksMode: always # async task execution — see MCP Tasks below
|
||||
```
|
||||
|
||||
## CLI Reference
|
||||
@@ -179,19 +202,27 @@ mcpServers:
|
||||
--compact Enable compact output mode
|
||||
--auto-compact Auto-compact conversation near context limit
|
||||
|
||||
# Extensions
|
||||
# Extensions and tools
|
||||
--extension, -e Load additional extension file(s) (repeatable)
|
||||
--no-extensions Disable all extensions
|
||||
--no-core-tools Disable all built-in core tools (bash, read, write, edit, grep, find, ls, subagent)
|
||||
--prompt-template Load a specific prompt template by name
|
||||
--no-prompt-templates Disable prompt template loading
|
||||
|
||||
# Skills
|
||||
--skill Load skill file or directory (repeatable)
|
||||
--skills-dir Override the project-local skills directory for auto-discovery
|
||||
--no-skills Disable skill loading (auto-discovery and explicit)
|
||||
|
||||
# Generation parameters
|
||||
--max-tokens Maximum tokens in response (default: 4096)
|
||||
--max-tokens Maximum tokens in response (default: 8192, auto-raised up to 32768 for models with larger known output limits)
|
||||
--temperature Randomness 0.0-1.0 (default: 0.7)
|
||||
--top-p Nucleus sampling 0.0-1.0 (default: 0.95)
|
||||
--top-k Limit top K tokens (default: 40)
|
||||
--stop-sequences Custom stop sequences (comma-separated)
|
||||
--thinking-level Extended thinking level: off, minimal, low, medium, high (default: off)
|
||||
--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)
|
||||
|
||||
# System
|
||||
--config Config file path (default: ~/.kit.yml)
|
||||
@@ -203,9 +234,14 @@ mcpServers:
|
||||
|
||||
```bash
|
||||
# Authentication (for OAuth-enabled providers)
|
||||
kit auth login [provider] # Start OAuth flow (e.g., anthropic)
|
||||
kit auth logout [provider] # Remove credentials for provider
|
||||
kit auth status # Check authentication status
|
||||
kit auth login [provider] # Start OAuth flow (e.g., anthropic)
|
||||
kit auth login [provider] --set-default # Set provider's default model as system default
|
||||
kit auth logout [provider] # Remove credentials for provider
|
||||
kit auth status # Check authentication status
|
||||
|
||||
# GitHub Copilot login (experimental; requires active Copilot subscription)
|
||||
kit auth login copilot
|
||||
kit --model copilot/gpt-5.5 "Hello"
|
||||
|
||||
# Model database
|
||||
kit models [provider] # List available models (optionally filter by provider)
|
||||
@@ -287,12 +323,15 @@ kit -e examples/extensions/minimal.go
|
||||
|
||||
### Extension Capabilities
|
||||
|
||||
**Lifecycle Events**: OnSessionStart, OnSessionShutdown, OnBeforeAgentStart, OnAgentStart, OnAgentEnd, OnToolCall, OnToolExecutionStart, OnToolOutput, OnToolExecutionEnd, OnToolResult, OnInput, OnMessageStart, OnMessageUpdate, OnMessageEnd, OnModelChange, OnContextPrepare, OnBeforeFork, OnBeforeSessionSwitch, OnBeforeCompact, OnCustomEvent, OnSubagentStart, OnSubagentChunk, OnSubagentEnd
|
||||
**Lifecycle Events**: OnSessionStart, OnSessionShutdown, OnBeforeAgentStart, OnAgentStart, OnAgentEnd, OnLLMUsage, OnToolCall, OnToolCallInputStart, OnToolCallInputDelta, OnToolCallInputEnd, OnToolExecutionStart, OnToolOutput, OnToolExecutionEnd, OnToolResult, OnInput, OnMessageStart, OnMessageUpdate, OnMessageEnd, OnModelChange, OnContextPrepare, OnBeforeFork, OnBeforeSessionSwitch, OnBeforeCompact, OnCustomEvent, OnSubagentStart, OnSubagentChunk, OnSubagentEnd
|
||||
|
||||
`OnAgentEnd` carries per-turn aggregates (`ToolCallCount`, `ToolNames`, `LLMCallCount`, `InputTokensDelta`, `OutputTokensDelta`, `CostDelta`, `DurationMs`) so observers don't need to maintain parallel bookkeeping. `OnLLMUsage` fires after each LLM provider call with token + cost deltas attributed to that specific call/model — use it for accurate budget enforcement *between* calls instead of waiting for the turn to finish.
|
||||
|
||||
**Custom Components**:
|
||||
- **Tools**: Add new tools the LLM can invoke
|
||||
- **Commands**: Register slash commands (e.g., `/mycommand`)
|
||||
- **Options**: Register configurable extension options
|
||||
- **Session State**: Last-write-wins key-value store via `ctx.SetState` / `GetState` / `DeleteState` / `ListState`, persisted to a per-session sidecar file outside the conversation tree
|
||||
- **Widgets**: Persistent status displays above/below input
|
||||
- **Headers/Footers**: Persistent content above/below the conversation
|
||||
- **Status Bar**: Custom status bar entries
|
||||
@@ -321,6 +360,7 @@ See the `examples/extensions/` directory:
|
||||
- [`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
|
||||
@@ -347,6 +387,7 @@ See the `examples/extensions/` directory:
|
||||
- [`tool-logger.go`](examples/extensions/tool-logger.go) - Log all tool calls
|
||||
- [`neon-theme.go`](examples/extensions/neon-theme.go) - Custom theme registration and switching
|
||||
- [`tool-renderer-demo.go`](examples/extensions/tool-renderer-demo.go) - Custom tool call rendering
|
||||
- [`usage-budget.go`](examples/extensions/usage-budget.go) - Per-call usage callback (`OnLLMUsage`), session state, and enriched `OnAgentEnd` per-turn report
|
||||
- [`widget-status.go`](examples/extensions/widget-status.go) - Persistent status widgets
|
||||
|
||||
Also see [`.kit/extensions/go-edit-lint.go`](.kit/extensions/go-edit-lint.go) (in this repo) for a project-local extension example that runs gopls and golangci-lint on Go file edits.
|
||||
@@ -428,10 +469,13 @@ Focus on $1 specifically.
|
||||
|
||||
**Argument placeholders:**
|
||||
- `$1`, `$2`, etc. — Individual arguments
|
||||
- `$@` or `$ARGUMENTS` — All arguments
|
||||
- `$@` or `$ARGUMENTS` — All arguments (zero or more)
|
||||
- `$+` — All arguments (one or more required; error if none given)
|
||||
- `${@: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
|
||||
@@ -480,6 +524,17 @@ During an interactive session, use these slash commands:
|
||||
| `/fork` | Fork to new session from an earlier message |
|
||||
| `/new` | Start a fresh session |
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
| Shortcut | Description |
|
||||
|----------|-------------|
|
||||
| `Ctrl+V` | Paste an image from the clipboard — shows an inline low-res thumbnail preview (tmux/zellij-safe) |
|
||||
| `Ctrl+U` | Clear all pending image attachments |
|
||||
| `Ctrl+X e` | Open `$VISUAL`/`$EDITOR` to compose or edit your prompt |
|
||||
| `Ctrl+X s` | Steer — inject a system-level instruction mid-turn |
|
||||
| `ESC ESC` | Cancel the current operation (tool call or streaming) |
|
||||
| `↑` / `↓` | Navigate prompt history |
|
||||
|
||||
## Go SDK
|
||||
|
||||
Embed Kit in your Go applications:
|
||||
@@ -522,9 +577,23 @@ host, err := kit.New(ctx, &kit.Options{
|
||||
SystemPrompt: "You are a helpful bot",
|
||||
ConfigFile: "/path/to/config.yml",
|
||||
MaxSteps: 10,
|
||||
Streaming: true,
|
||||
Streaming: ptr(true), // *bool: nil = unset (default true), &false = off
|
||||
Quiet: true,
|
||||
|
||||
// Generation parameters (override env/config/per-model defaults)
|
||||
MaxTokens: 16384, // 0 = auto-resolve (env → config → per-model → 8192 floor)
|
||||
ThinkingLevel: "medium", // "off", "none", "minimal", "low", "medium", "high"
|
||||
Temperature: ptr(float32(0.2)), // pointer so 0.0 != unset; nil = provider default
|
||||
TopP: nil, // nil = leave provider/per-model default
|
||||
TopK: nil,
|
||||
FrequencyPenalty: nil,
|
||||
PresencePenalty: nil,
|
||||
|
||||
// Provider configuration (override env/config without reaching into viper)
|
||||
ProviderAPIKey: "sk-...", // "" = use config / provider env var
|
||||
ProviderURL: "https://proxy.internal/v1", // "" = provider default
|
||||
TLSSkipVerify: false, // only takes effect when true
|
||||
|
||||
// Session options
|
||||
SessionPath: "./session.jsonl", // Open specific session
|
||||
Continue: true, // Resume most recent session
|
||||
@@ -533,7 +602,9 @@ host, err := kit.New(ctx, &kit.Options{
|
||||
// Tool options
|
||||
Tools: []kit.Tool{...}, // Replace default tool set entirely
|
||||
ExtraTools: []kit.Tool{...}, // Add tools alongside defaults
|
||||
DisableCoreTools: true, // Use no core tools (0 tools, for chat-only)
|
||||
DisableCoreTools: true, // Disable all built-in core tools; also controllable via
|
||||
// --no-core-tools flag, KIT_NO_CORE_TOOLS env var,
|
||||
// or no-core-tools: true in .kit.yml
|
||||
|
||||
// Configuration
|
||||
SkipConfig: true, // Skip .kit.yml files (viper defaults + env vars still apply)
|
||||
@@ -545,6 +616,108 @@ host, err := kit.New(ctx, &kit.Options{
|
||||
})
|
||||
```
|
||||
|
||||
**Generation & provider fields** (added in v0.55+) let SDK consumers configure
|
||||
Kit entirely in-code without `viper.Set()` workarounds or shipping a `.kit.yml`.
|
||||
Precedence is `Options` > `KIT_*` env vars > `.kit.yml` > per-model defaults
|
||||
(`modelSettings` / `customModels`) > provider-level defaults. Sampling params
|
||||
are pointer types so explicit `0.0` is distinguishable from "leave alone"; a
|
||||
non-zero `MaxTokens` suppresses automatic right-sizing the same way `--max-tokens`
|
||||
does on the CLI.
|
||||
|
||||
### Functional options (`NewAgent`)
|
||||
|
||||
For simple programmatic setups, `kit.NewAgent` offers an ergonomic
|
||||
functional-options front door over `kit.New`. Streaming is **enabled by
|
||||
default**; pass `kit.WithStreaming(false)` to opt out.
|
||||
|
||||
```go
|
||||
host, err := kit.NewAgent(ctx,
|
||||
kit.WithModel("anthropic/claude-sonnet-4-5-20250929"),
|
||||
kit.WithSystemPrompt("You are a helpful assistant."),
|
||||
kit.WithMaxTokens(8192),
|
||||
kit.WithThinkingLevel("medium"),
|
||||
kit.Ephemeral(), // in-memory session, no persistence
|
||||
)
|
||||
```
|
||||
|
||||
Available options: `WithModel`, `WithSystemPrompt`, `WithStreaming`,
|
||||
`WithMaxTokens`, `WithThinkingLevel`, `WithTools`, `WithExtraTools`,
|
||||
`WithProviderAPIKey`, `WithProviderURL`, `WithConfigFile`, `WithDebug`, and
|
||||
`Ephemeral`. For advanced configuration not covered by the helpers (custom MCP
|
||||
config, in-process MCP servers, session backends, MCP task tuning) construct an
|
||||
`Options` value explicitly and call `kit.New`.
|
||||
|
||||
### Per-instance config isolation
|
||||
|
||||
Each `kit.New` / `kit.NewAgent` call owns an **isolated configuration store**,
|
||||
so constructing multiple Kit instances in the same process is safe: setting the
|
||||
model, thinking level, or generation parameters on one never affects another,
|
||||
and runtime mutators (`SetModel`, `SetThinkingLevel`) only touch the owning
|
||||
instance. This makes subagent spawning and multi-Kit embedding race-free with
|
||||
no external synchronization required.
|
||||
|
||||
### MCP OAuth (remote MCP servers)
|
||||
|
||||
When a remote MCP server returns 401, Kit runs the full OAuth flow (dynamic
|
||||
client registration → PKCE → token exchange → persistence) but delegates the
|
||||
user-facing step — showing the authorization URL and receiving the callback —
|
||||
to an `MCPAuthHandler` that you pass explicitly via `Options.MCPAuthHandler`.
|
||||
If nil, OAuth is disabled and the authorization-required error surfaces to the
|
||||
caller; the SDK never auto-opens a browser or binds a localhost port.
|
||||
|
||||
```go
|
||||
// CLI/TUI apps: opens the system browser + prints status to stderr.
|
||||
authHandler, _ := kit.NewCLIMCPAuthHandler()
|
||||
defer authHandler.Close()
|
||||
|
||||
host, _ := kit.New(ctx, &kit.Options{
|
||||
MCPAuthHandler: authHandler,
|
||||
})
|
||||
|
||||
// Custom UX: reuse the SDK's port + callback server, supply your own
|
||||
// presentation via OnAuthURL (TUI modal, QR code, web redirect, etc.).
|
||||
// h, _ := kit.NewDefaultMCPAuthHandler()
|
||||
// h.OnAuthURL = func(server, authURL string) { myUI.Show(server, authURL) }
|
||||
//
|
||||
// Full control (web apps, daemons): implement kit.MCPAuthHandler yourself —
|
||||
// no localhost binding, no side effects.
|
||||
```
|
||||
|
||||
Tokens are persisted to `$XDG_CONFIG_HOME/.kit/mcp_tokens.json` by default; swap
|
||||
in a custom `MCPTokenStoreFactory` for encrypted, DB-backed, or in-memory
|
||||
storage. See the [SDK options docs](/sdk/options#mcp-oauth-authorization) for
|
||||
the full matrix.
|
||||
|
||||
### MCP Tasks (long-running tools)
|
||||
|
||||
Kit advertises [MCP task support](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks)
|
||||
during `initialize`, so cooperating MCP servers can respond to `tools/call`
|
||||
with a `taskId` instead of blocking the connection. Kit then polls
|
||||
`tasks/get` / `tasks/result` until the task reaches a terminal state, and
|
||||
best-effort `tasks/cancel`s on context cancellation.
|
||||
|
||||
Defaults are safe — a server that doesn't advertise task capability runs
|
||||
synchronously, exactly as before. Opt in per server via `tasksMode` in
|
||||
`.kit.yml` (`auto` | `never` | `always`) or programmatically through the SDK:
|
||||
|
||||
```go
|
||||
host, _ := kit.New(ctx, &kit.Options{
|
||||
MCPTaskMode: map[string]kit.MCPTaskMode{
|
||||
"build-server": kit.MCPTaskModeAlways,
|
||||
},
|
||||
MCPTaskTimeout: 15 * time.Minute,
|
||||
MCPTaskProgress: func(p kit.MCPTaskProgress) {
|
||||
log.Printf("%s: %s", p.TaskID, p.Status)
|
||||
},
|
||||
})
|
||||
|
||||
tasks, _ := host.ListMCPTasks(ctx, "build-server")
|
||||
_, _ = host.CancelMCPTask(ctx, "build-server", tasks[0].TaskID)
|
||||
```
|
||||
|
||||
See the [configuration docs](/configuration#mcp-tasks-long-running-tools) and
|
||||
[SDK options → MCP Tasks](/sdk/options#mcp-tasks) for the full surface.
|
||||
|
||||
### Custom Tools
|
||||
|
||||
Create custom tools with automatic schema generation — no external dependencies needed:
|
||||
@@ -565,7 +738,28 @@ host, _ := kit.New(ctx, &kit.Options{
|
||||
})
|
||||
```
|
||||
|
||||
Use `kit.NewParallelTool` for tools safe to run concurrently. See the [SDK docs](/sdk/overview) for full details on struct tags, `ToolOutput` fields, and `ToolCallIDFromContext`.
|
||||
Use `kit.NewParallelTool` for tools safe to run concurrently. Binary data (images, audio, etc.) in `ToolOutput.Data` is automatically forwarded to the LLM when `MediaType` is set. See the [SDK docs](/sdk/overview) for full details on struct tags, `ToolOutput` fields, and `ToolCallIDFromContext`.
|
||||
|
||||
#### Return Helpers
|
||||
|
||||
| Helper | Description |
|
||||
| --- | --- |
|
||||
| `kit.TextResult(content)` | Successful text result |
|
||||
| `kit.ErrorResult(content)` | Error result (LLM sees it as a tool error) |
|
||||
| `kit.ImageResult(content, data, mediaType)` | Image result with binary data (e.g. `"image/png"`) |
|
||||
| `kit.MediaResult(content, data, mediaType)` | Non-image media result (e.g. `"audio/mpeg"`) |
|
||||
|
||||
#### ToolOutput Fields
|
||||
|
||||
```go
|
||||
kit.ToolOutput{
|
||||
Content: "result text", // text returned to the LLM
|
||||
IsError: false, // true = LLM sees this as an error
|
||||
Data: pngBytes, // optional binary data (images, audio)
|
||||
MediaType: "image/png", // MIME type for binary Data
|
||||
Metadata: map[string]any{}, // opaque metadata for hooks/UI (not sent to LLM)
|
||||
}
|
||||
```
|
||||
|
||||
### With Callbacks
|
||||
|
||||
@@ -582,7 +776,7 @@ unsub2 := host.OnToolResult(func(e kit.ToolResultEvent) {
|
||||
})
|
||||
defer unsub2()
|
||||
|
||||
unsub3 := host.OnStreaming(func(e kit.MessageUpdateEvent) {
|
||||
unsub3 := host.OnMessageUpdate(func(e kit.MessageUpdateEvent) {
|
||||
print(e.Chunk)
|
||||
})
|
||||
defer unsub3()
|
||||
@@ -619,6 +813,45 @@ host, _ := kit.New(ctx, &kit.Options{
|
||||
})
|
||||
```
|
||||
|
||||
### Runtime Skills & Context Files
|
||||
|
||||
For multi-tenant hosts (chatbots, per-user agents, web services), the SDK
|
||||
lets you swap skills and `AGENTS.md`-style context files **after** Kit
|
||||
construction. Every mutation recomposes the system prompt and applies it to
|
||||
the agent so the next turn picks up the new instructions — no restart needed.
|
||||
|
||||
```go
|
||||
// Programmatic skill (no file on disk required).
|
||||
host.AddSkill(&kit.Skill{
|
||||
Name: "polite-french",
|
||||
Description: "Respond in French and always greet the user.",
|
||||
Content: "Always reply in French. Open every response with 'Bonjour'.",
|
||||
})
|
||||
|
||||
// Or load one from disk.
|
||||
host.LoadAndAddSkill("/var/skills/refund-policy.md")
|
||||
|
||||
// Per-user AGENTS.md content pulled from a database.
|
||||
host.AddContextFileContent(
|
||||
fmt.Sprintf("session://%s/AGENTS.md", userID),
|
||||
rulesFromDB,
|
||||
)
|
||||
|
||||
// Tear down session-specific state on logout.
|
||||
host.RemoveSkill("polite-french")
|
||||
host.RemoveContextFile(fmt.Sprintf("session://%s/AGENTS.md", userID))
|
||||
|
||||
// Or replace the whole set atomically.
|
||||
host.SetSkills(activeSkillsForUser)
|
||||
host.SetContextFiles(activeContextForUser)
|
||||
```
|
||||
|
||||
Skills dedupe by `Name`, context files dedupe by `Path` (which can be any
|
||||
opaque identifier — it doesn't have to be a real filesystem path). All
|
||||
mutators and readers (`GetSkills`, `GetContextFiles`) are safe to call
|
||||
concurrently from multiple goroutines. See the [SDK overview docs](/sdk/overview#runtime-skills-and-context-files)
|
||||
for the full reference.
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Subagent Pattern
|
||||
@@ -735,6 +968,7 @@ npm/ - NPM package wrapper for distribution
|
||||
|
||||
- **Anthropic** - Claude models (native, prompt caching, OAuth)
|
||||
- **OpenAI** - GPT models
|
||||
- **Copilot** - GitHub Copilot models (`copilot`, requires active Copilot subscription)
|
||||
- **Google** - Gemini models
|
||||
- **Ollama** - Local models
|
||||
- **Azure OpenAI** - Azure-hosted OpenAI
|
||||
@@ -760,6 +994,31 @@ This automatically defaults to `custom/custom` without needing to specify a mode
|
||||
- Reasoning and temperature support
|
||||
- Optional `CUSTOM_API_KEY` environment variable or `--provider-api-key` flag
|
||||
|
||||
### Auto-routed Providers
|
||||
|
||||
Any provider in the [models.dev](https://models.dev) database can be used as
|
||||
`provider/model` without a dedicated native integration. Kit auto-routes the
|
||||
request through the matching **wire protocol** based on the provider's npm package
|
||||
(or per-model override), using its `api` URL as the base:
|
||||
|
||||
| npm package | Wire protocol |
|
||||
|-------------|---------------|
|
||||
| `@ai-sdk/openai` | OpenAI (Responses API) |
|
||||
| `@ai-sdk/openai-compatible` | OpenAI (chat completions) |
|
||||
| `@ai-sdk/anthropic` | Anthropic |
|
||||
| `@ai-sdk/google` | Google Gemini |
|
||||
|
||||
Providers with an `api` URL but an unrecognized npm package fall back to the
|
||||
OpenAI-compatible wire. Because routing follows the wire protocol, aggregator/proxy
|
||||
providers work across all of their models — including Claude, GPT, *and* Gemini
|
||||
routes:
|
||||
|
||||
```bash
|
||||
kit --model opencode/claude-haiku-4-5 "Hello" # → Anthropic wire
|
||||
kit --model opencode/gpt-5 "Hello" # → OpenAI wire
|
||||
kit --model opencode/gemini-3.5-flash "Hello" # → Google wire
|
||||
```
|
||||
|
||||
### Model String Format
|
||||
|
||||
```bash
|
||||
|
||||
+220
-7
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"charm.land/huh/v2"
|
||||
"github.com/mark3labs/kit/internal/auth"
|
||||
"github.com/mark3labs/kit/internal/ui"
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -30,10 +31,12 @@ using OAuth flows. Stored credentials take precedence over environment variables
|
||||
Available providers:
|
||||
- anthropic: Anthropic Claude API (OAuth)
|
||||
- openai: OpenAI API (OAuth and API key)
|
||||
- copilot: GitHub Copilot (GitHub device login)
|
||||
|
||||
Examples:
|
||||
kit auth login anthropic
|
||||
kit auth login openai
|
||||
kit auth login copilot
|
||||
kit auth logout anthropic
|
||||
kit auth status`,
|
||||
}
|
||||
@@ -53,10 +56,16 @@ environment variables when making API calls.
|
||||
Available providers:
|
||||
- anthropic: Anthropic Claude API (OAuth)
|
||||
- openai: OpenAI ChatGPT Plus/Pro (Codex OAuth)
|
||||
- copilot: GitHub Copilot (GitHub device login, experimental)
|
||||
|
||||
Example:
|
||||
Flags:
|
||||
--set-default Set this provider's default model as the system default
|
||||
|
||||
Examples:
|
||||
kit auth login anthropic
|
||||
kit auth login openai`,
|
||||
kit auth login openai
|
||||
kit auth login copilot
|
||||
kit auth login copilot --set-default`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runAuthLogin,
|
||||
}
|
||||
@@ -75,10 +84,12 @@ You will need to use environment variables or command-line flags for authenticat
|
||||
Available providers:
|
||||
- anthropic: Anthropic Claude API
|
||||
- openai: OpenAI API
|
||||
- copilot: GitHub Copilot
|
||||
|
||||
Example:
|
||||
kit auth logout anthropic
|
||||
kit auth logout openai`,
|
||||
kit auth logout openai
|
||||
kit auth logout copilot`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runAuthLogout,
|
||||
}
|
||||
@@ -99,12 +110,47 @@ Example:
|
||||
RunE: runAuthStatus,
|
||||
}
|
||||
|
||||
var (
|
||||
loginSetDefault bool
|
||||
)
|
||||
|
||||
// defaultModels maps providers to their recommended default models.
|
||||
// These are used when --set-default flag is passed to auth login.
|
||||
var defaultModels = map[string]string{
|
||||
"anthropic": "anthropic/claude-sonnet-4-5-20250929",
|
||||
"openai": "openai/gpt-5.4",
|
||||
"copilot": "copilot/gpt-5.5",
|
||||
}
|
||||
|
||||
// setDefaultModelIfRequested sets the default model for the given provider
|
||||
// if the --set-default flag was provided.
|
||||
func setDefaultModelIfRequested(provider string) error {
|
||||
if !loginSetDefault {
|
||||
return nil
|
||||
}
|
||||
|
||||
model, ok := defaultModels[provider]
|
||||
if !ok {
|
||||
return fmt.Errorf("no default model configured for provider: %s", provider)
|
||||
}
|
||||
|
||||
if err := ui.SaveModelPreference(model); err != nil {
|
||||
return fmt.Errorf("failed to save model preference: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\n✓ Set default model to: %s\n", model)
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
authCmd.AddCommand(authLoginCmd)
|
||||
authCmd.AddCommand(authLogoutCmd)
|
||||
authCmd.AddCommand(authStatusCmd)
|
||||
|
||||
authLoginCmd.Flags().BoolVar(&loginSetDefault, "set-default", false, "Set this provider's default model as the system default after login")
|
||||
}
|
||||
|
||||
// runAuthLogin dispatches OAuth login to the selected provider.
|
||||
func runAuthLogin(cmd *cobra.Command, args []string) error {
|
||||
provider := strings.ToLower(args[0])
|
||||
|
||||
@@ -113,8 +159,10 @@ func runAuthLogin(cmd *cobra.Command, args []string) error {
|
||||
return loginAnthropic()
|
||||
case "openai":
|
||||
return loginOpenAI()
|
||||
case "copilot":
|
||||
return loginCopilot(cmd.Context())
|
||||
default:
|
||||
return fmt.Errorf("unsupported provider: %s. Available providers: anthropic, openai", provider)
|
||||
return fmt.Errorf("unsupported provider: %s. Available providers: anthropic, openai, copilot", provider)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,8 +174,10 @@ func runAuthLogout(cmd *cobra.Command, args []string) error {
|
||||
return logoutAnthropic()
|
||||
case "openai":
|
||||
return logoutOpenAI()
|
||||
case "copilot":
|
||||
return logoutCopilot()
|
||||
default:
|
||||
return fmt.Errorf("unsupported provider: %s. Available providers: anthropic, openai", provider)
|
||||
return fmt.Errorf("unsupported provider: %s. Available providers: anthropic, openai, copilot", provider)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,9 +256,31 @@ func runAuthStatus(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Check GitHub Copilot credentials
|
||||
fmt.Print("\nGitHub Copilot: ")
|
||||
if hasCopilotCreds, err := cm.HasCopilotCredentials(); err != nil {
|
||||
fmt.Printf("Error checking credentials: %v\n", err)
|
||||
} else if hasCopilotCreds {
|
||||
if creds, err := cm.GetCopilotCredentials(); err != nil {
|
||||
fmt.Printf("Error reading credentials: %v\n", err)
|
||||
} else {
|
||||
status := "✓ Authenticated"
|
||||
if creds.IsExpired() {
|
||||
status = "⚠️ Token expired (will refresh automatically)"
|
||||
} else if creds.NeedsRefresh() {
|
||||
status = "⚠️ Token expires soon (will refresh automatically)"
|
||||
}
|
||||
|
||||
fmt.Printf("%s (GitHub OAuth, stored %s)\n", status, creds.CreatedAt.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
} else {
|
||||
fmt.Println("✗ Not authenticated")
|
||||
}
|
||||
|
||||
fmt.Println("\nTo authenticate with a provider:")
|
||||
fmt.Println(" kit auth login anthropic")
|
||||
fmt.Println(" kit auth login openai")
|
||||
fmt.Println(" kit auth login copilot")
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -288,6 +360,17 @@ func loginAnthropic() error {
|
||||
fmt.Println("\n🎉 Your OAuth credentials will now be used for Anthropic API calls.")
|
||||
fmt.Println("💡 You can check your authentication status with: kit auth status")
|
||||
|
||||
// Set default model if requested
|
||||
if err := setDefaultModelIfRequested("anthropic"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remind users how to set this as default if they didn't use --set-default
|
||||
if !loginSetDefault {
|
||||
fmt.Println("\n💡 To set Anthropic as your default model, run:")
|
||||
fmt.Println(" kit auth login anthropic --set-default")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -454,6 +537,96 @@ func loginOpenAI() error {
|
||||
fmt.Println("\n🎉 Your OAuth credentials will now be used for OpenAI API calls.")
|
||||
fmt.Println("💡 You can check your authentication status with: kit auth status")
|
||||
|
||||
// Set default model if requested
|
||||
if err := setDefaultModelIfRequested("openai"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remind users how to set this as default if they didn't use --set-default
|
||||
if !loginSetDefault {
|
||||
fmt.Println("\n💡 To set OpenAI as your default model, run:")
|
||||
fmt.Println(" kit auth login openai --set-default")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loginCopilot authenticates GitHub Copilot using GitHub device flow.
|
||||
func loginCopilot(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
cm, err := kit.NewCredentialManager()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize credential manager: %w", err)
|
||||
}
|
||||
|
||||
if hasAuth, err := cm.HasCopilotCredentials(); err == nil && hasAuth {
|
||||
var reauth bool
|
||||
err := huh.NewConfirm().
|
||||
Title("You are already authenticated with GitHub Copilot").
|
||||
Description("Do you want to re-authenticate?").
|
||||
Affirmative("Yes").
|
||||
Negative("No").
|
||||
Value(&reauth).
|
||||
Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to prompt for re-authentication: %w", err)
|
||||
}
|
||||
if !reauth {
|
||||
fmt.Println("Authentication cancelled.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
client := auth.NewCopilotOAuthClient()
|
||||
|
||||
fmt.Println("🔐 Starting GitHub Copilot authentication...")
|
||||
fmt.Println("This uses GitHub device login and requires an active GitHub Copilot subscription.")
|
||||
fmt.Println("Experimental: this uses VS Code Copilot Chat client identifiers.")
|
||||
fmt.Println()
|
||||
|
||||
deviceCode, err := client.StartDeviceFlow(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start GitHub device login: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("📱 Open this page and enter the code:")
|
||||
fmt.Printf("\n%s\n\n", deviceCode.VerificationURI)
|
||||
fmt.Printf("Code: %s\n\n", deviceCode.UserCode)
|
||||
auth.TryOpenBrowser(deviceCode.VerificationURI)
|
||||
|
||||
fmt.Println("Waiting for GitHub authorization...")
|
||||
githubToken, err := client.PollDeviceToken(ctx, deviceCode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to complete GitHub device login: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("\n🔄 Exchanging GitHub token for Copilot access token...")
|
||||
creds, err := client.ExchangeGitHubToken(ctx, githubToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get GitHub Copilot token: %w", err)
|
||||
}
|
||||
|
||||
if err := cm.SetCopilotOAuthCredentials(creds); err != nil {
|
||||
return fmt.Errorf("failed to store credentials: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("✅ Successfully authenticated with GitHub Copilot!")
|
||||
fmt.Printf("📁 Credentials stored in: %s\n", cm.GetCredentialsPath())
|
||||
fmt.Println("\n🎉 Your GitHub Copilot credentials will now be used for copilot/* models.")
|
||||
fmt.Println("💡 You can check your authentication status with: kit auth status")
|
||||
|
||||
if err := setDefaultModelIfRequested("copilot"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !loginSetDefault {
|
||||
fmt.Println("\n💡 To set Copilot as your default model, run:")
|
||||
fmt.Println(" kit auth login copilot --set-default")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -504,13 +677,13 @@ func startOpenAICallbackServer(expectedState string) (*callbackServer, error) {
|
||||
}
|
||||
|
||||
// Return success page
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = fmt.Fprintf(w, `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Authentication Successful</title></head>
|
||||
<body style="font-family: sans-serif; text-align: center; padding: 50px;">
|
||||
<h1>✓ Authentication Successful</h1>
|
||||
<h1>✓ Authentication Successful</h1>
|
||||
<p>You can close this window and return to the terminal.</p>
|
||||
</body>
|
||||
</html>`)
|
||||
@@ -575,3 +748,43 @@ func logoutOpenAI() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func logoutCopilot() error {
|
||||
cm, err := kit.NewCredentialManager()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize credential manager: %w", err)
|
||||
}
|
||||
|
||||
hasAuth, err := cm.HasCopilotCredentials()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check authentication status: %w", err)
|
||||
}
|
||||
|
||||
if !hasAuth {
|
||||
fmt.Println("You are not currently authenticated with GitHub Copilot.")
|
||||
return nil
|
||||
}
|
||||
|
||||
var confirm bool
|
||||
err = huh.NewConfirm().
|
||||
Title("Remove GitHub Copilot credentials").
|
||||
Description("Are you sure you want to remove your stored credentials?").
|
||||
Affirmative("Yes").
|
||||
Negative("No").
|
||||
Value(&confirm).
|
||||
Run()
|
||||
if err != nil || !confirm {
|
||||
fmt.Println("Logout cancelled.")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := cm.RemoveCopilotCredentials(); err != nil {
|
||||
return fmt.Errorf("failed to remove credentials: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("✓ Successfully logged out from GitHub Copilot!")
|
||||
fmt.Println("You will need to authenticate again with 'kit auth login copilot'.")
|
||||
fmt.Println("Tip: this removes local credentials only. Revoke the GitHub OAuth grant at https://github.com/settings/applications")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"golang.org/x/term"
|
||||
|
||||
"github.com/mark3labs/kit/internal/app"
|
||||
"github.com/mark3labs/kit/internal/extbridge"
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
"github.com/mark3labs/kit/internal/models"
|
||||
"github.com/mark3labs/kit/internal/ui"
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
)
|
||||
|
||||
// extensionContextDeps groups the runtime dependencies needed to wire up
|
||||
// an extensions.Context for the interactive TUI mode.
|
||||
type extensionContextDeps struct {
|
||||
ctx context.Context
|
||||
cwd string
|
||||
modelName string
|
||||
interactive bool
|
||||
kitInstance *kit.Kit
|
||||
appInstance *app.App
|
||||
usageTracker *ui.UsageTracker
|
||||
}
|
||||
|
||||
// buildInteractiveExtensionContext returns an extensions.Context with every
|
||||
// field except Print / PrintInfo / PrintError populated. Callers must set
|
||||
// the three print routes appropriately for their phase (startup buffering
|
||||
// vs. live runtime routing).
|
||||
//
|
||||
// The headless half (data access, state, options, tree navigation, skills,
|
||||
// templates, model resolution, subagents) comes from extbridge.BaseContext;
|
||||
// this function overlays the TUI-specific fields and overrides SetModel /
|
||||
// ReloadExtensions with TUI-aware versions.
|
||||
func buildInteractiveExtensionContext(deps extensionContextDeps) extensions.Context {
|
||||
kitInstance := deps.kitInstance
|
||||
appInstance := deps.appInstance
|
||||
usageTracker := deps.usageTracker
|
||||
|
||||
ec := extbridge.BaseContext(deps.ctx, kitInstance)
|
||||
|
||||
ec.CWD = deps.cwd
|
||||
ec.Model = deps.modelName
|
||||
ec.Interactive = deps.interactive
|
||||
|
||||
ec.PrintBlock = func(opts extensions.PrintBlockOpts) {
|
||||
appInstance.PrintBlockFromExtension(opts)
|
||||
}
|
||||
ec.SendMessage = func(text string) { appInstance.Run(text) }
|
||||
ec.CancelAndSend = func(text string) { appInstance.InterruptAndSend(text) }
|
||||
ec.Abort = func() { appInstance.Abort() }
|
||||
ec.IsIdle = func() bool { return !appInstance.IsBusy() }
|
||||
ec.Compact = func(cfg extensions.CompactConfig) error {
|
||||
return appInstance.CompactAsync(cfg.CustomInstructions, cfg.OnComplete, cfg.OnError)
|
||||
}
|
||||
ec.SendMultimodalMessage = func(text string, files []extensions.FilePart) {
|
||||
parts := make([]kit.LLMFilePart, len(files))
|
||||
for i, f := range files {
|
||||
parts[i] = kit.LLMFilePart{
|
||||
Filename: f.Filename,
|
||||
Data: f.Data,
|
||||
MediaType: f.MediaType,
|
||||
}
|
||||
}
|
||||
appInstance.RunWithFiles(text, parts)
|
||||
}
|
||||
ec.GetSessionUsage = func() extensions.SessionUsage {
|
||||
if usageTracker == nil {
|
||||
return extensions.SessionUsage{}
|
||||
}
|
||||
stats := usageTracker.GetSessionStats()
|
||||
return extensions.SessionUsage{
|
||||
TotalInputTokens: stats.TotalInputTokens,
|
||||
TotalOutputTokens: stats.TotalOutputTokens,
|
||||
TotalCacheReadTokens: stats.TotalCacheReadTokens,
|
||||
TotalCacheWriteTokens: stats.TotalCacheWriteTokens,
|
||||
TotalCost: stats.TotalCost,
|
||||
RequestCount: stats.RequestCount,
|
||||
}
|
||||
}
|
||||
ec.Exit = func() { appInstance.QuitFromExtension() }
|
||||
|
||||
// TUI widgets/chrome — mutate runner state, then notify the TUI.
|
||||
// Always use a goroutine for NotifyWidgetUpdate: prog.Send() deadlocks
|
||||
// if called synchronously from inside BubbleTea's Update() handler.
|
||||
// All call sites use go-routines uniformly.
|
||||
ec.SetWidget = func(config extensions.WidgetConfig) {
|
||||
kitInstance.Extensions().SetWidget(config)
|
||||
go appInstance.NotifyWidgetUpdate()
|
||||
}
|
||||
ec.RemoveWidget = func(id string) {
|
||||
kitInstance.Extensions().RemoveWidget(id)
|
||||
go appInstance.NotifyWidgetUpdate()
|
||||
}
|
||||
ec.SetHeader = func(config extensions.HeaderFooterConfig) {
|
||||
kitInstance.Extensions().SetHeader(config)
|
||||
go appInstance.NotifyWidgetUpdate()
|
||||
}
|
||||
ec.RemoveHeader = func() {
|
||||
kitInstance.Extensions().RemoveHeader()
|
||||
go appInstance.NotifyWidgetUpdate()
|
||||
}
|
||||
ec.SetFooter = func(config extensions.HeaderFooterConfig) {
|
||||
kitInstance.Extensions().SetFooter(config)
|
||||
go appInstance.NotifyWidgetUpdate()
|
||||
}
|
||||
ec.RemoveFooter = func() {
|
||||
kitInstance.Extensions().RemoveFooter()
|
||||
go appInstance.NotifyWidgetUpdate()
|
||||
}
|
||||
ec.SetUIVisibility = func(v extensions.UIVisibility) {
|
||||
kitInstance.Extensions().SetUIVisibility(v)
|
||||
go appInstance.NotifyWidgetUpdate()
|
||||
}
|
||||
ec.SetEditor = func(config extensions.EditorConfig) {
|
||||
kitInstance.Extensions().SetEditor(config)
|
||||
go appInstance.NotifyWidgetUpdate()
|
||||
}
|
||||
ec.ResetEditor = func() {
|
||||
kitInstance.Extensions().ResetEditor()
|
||||
go appInstance.NotifyWidgetUpdate()
|
||||
}
|
||||
ec.SetEditorText = func(text string) {
|
||||
appInstance.SetEditorTextFromExtension(text)
|
||||
}
|
||||
ec.SetStatus = func(key string, text string, priority int) {
|
||||
kitInstance.Extensions().SetStatus(extensions.StatusBarEntry{
|
||||
Key: key,
|
||||
Text: text,
|
||||
Priority: priority,
|
||||
})
|
||||
go appInstance.NotifyWidgetUpdate()
|
||||
}
|
||||
ec.RemoveStatus = func(key string) {
|
||||
kitInstance.Extensions().RemoveStatus(key)
|
||||
go appInstance.NotifyWidgetUpdate()
|
||||
}
|
||||
|
||||
// Interactive prompts — channel-based round trips through the TUI.
|
||||
ec.PromptSelect = func(config extensions.PromptSelectConfig) extensions.PromptSelectResult {
|
||||
ch := make(chan app.PromptResponse, 1)
|
||||
appInstance.SendPromptRequest(app.PromptRequestEvent{
|
||||
PromptType: "select",
|
||||
Message: config.Message,
|
||||
Options: config.Options,
|
||||
ResponseCh: ch,
|
||||
})
|
||||
resp := <-ch
|
||||
if resp.Cancelled {
|
||||
return extensions.PromptSelectResult{Cancelled: true}
|
||||
}
|
||||
return extensions.PromptSelectResult{Value: resp.Value, Index: resp.Index}
|
||||
}
|
||||
ec.PromptConfirm = func(config extensions.PromptConfirmConfig) extensions.PromptConfirmResult {
|
||||
ch := make(chan app.PromptResponse, 1)
|
||||
def := "false"
|
||||
if config.DefaultValue {
|
||||
def = "true"
|
||||
}
|
||||
appInstance.SendPromptRequest(app.PromptRequestEvent{
|
||||
PromptType: "confirm",
|
||||
Message: config.Message,
|
||||
Default: def,
|
||||
ResponseCh: ch,
|
||||
})
|
||||
resp := <-ch
|
||||
if resp.Cancelled {
|
||||
return extensions.PromptConfirmResult{Cancelled: true}
|
||||
}
|
||||
return extensions.PromptConfirmResult{Value: resp.Confirmed}
|
||||
}
|
||||
ec.PromptInput = func(config extensions.PromptInputConfig) extensions.PromptInputResult {
|
||||
ch := make(chan app.PromptResponse, 1)
|
||||
appInstance.SendPromptRequest(app.PromptRequestEvent{
|
||||
PromptType: "input",
|
||||
Message: config.Message,
|
||||
Placeholder: config.Placeholder,
|
||||
Default: config.Default,
|
||||
ResponseCh: ch,
|
||||
})
|
||||
resp := <-ch
|
||||
if resp.Cancelled {
|
||||
return extensions.PromptInputResult{Cancelled: true}
|
||||
}
|
||||
return extensions.PromptInputResult{Value: resp.Value}
|
||||
}
|
||||
ec.ShowOverlay = func(config extensions.OverlayConfig) extensions.OverlayResult {
|
||||
ch := make(chan app.OverlayResponse, 1)
|
||||
appInstance.SendOverlayRequest(app.OverlayRequestEvent{
|
||||
Title: config.Title,
|
||||
Content: config.Content.Text,
|
||||
Markdown: config.Content.Markdown,
|
||||
BorderColor: config.Style.BorderColor,
|
||||
Background: config.Style.Background,
|
||||
Width: config.Width,
|
||||
MaxHeight: config.MaxHeight,
|
||||
Anchor: string(config.Anchor),
|
||||
Actions: config.Actions,
|
||||
ResponseCh: ch,
|
||||
})
|
||||
resp := <-ch
|
||||
if resp.Cancelled {
|
||||
return extensions.OverlayResult{Cancelled: true, Index: -1}
|
||||
}
|
||||
return extensions.OverlayResult{
|
||||
Action: resp.Action,
|
||||
Index: resp.Index,
|
||||
}
|
||||
}
|
||||
ec.SuspendTUI = func(callback func()) error {
|
||||
return appInstance.SuspendTUI(callback)
|
||||
}
|
||||
|
||||
// TUI-aware model switch: also notifies the TUI status bar and
|
||||
// refreshes the usage tracker for correct token counting.
|
||||
ec.SetModel = func(modelString string) error {
|
||||
// Capture previous model for the ModelChange event.
|
||||
previousModel := kitInstance.Extensions().GetContext().Model
|
||||
err := kitInstance.SetModel(context.Background(), modelString)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Notify TUI so it updates model in status bar.
|
||||
p, m, _ := models.ParseModelString(modelString)
|
||||
appInstance.NotifyModelChanged(p, m)
|
||||
// Update the context's Model field so handlers see it.
|
||||
kitInstance.Extensions().UpdateContextModel(modelString)
|
||||
// Fire OnModelChange event to extensions.
|
||||
kitInstance.Extensions().EmitModelChange(modelString, previousModel, "extension")
|
||||
// Update usage tracker with new model info for correct token counting.
|
||||
ui.UpdateUsageTrackerForModel(usageTracker, modelString, viper.GetString("provider-api-key"))
|
||||
return nil
|
||||
}
|
||||
|
||||
ec.RenderMessage = func(rendererName, content string) {
|
||||
renderer := kitInstance.Extensions().GetMessageRenderer(rendererName)
|
||||
if renderer == nil || renderer.Render == nil {
|
||||
appInstance.PrintFromExtension("", content)
|
||||
return
|
||||
}
|
||||
w, _, _ := term.GetSize(int(os.Stdout.Fd()))
|
||||
if w == 0 {
|
||||
w = 80
|
||||
}
|
||||
rendered := renderer.Render(content, w)
|
||||
appInstance.PrintFromExtension("", rendered)
|
||||
}
|
||||
ec.ReloadExtensions = func() error {
|
||||
err := kitInstance.Extensions().Reload()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Notify TUI that widgets/status/commands may have changed.
|
||||
go appInstance.NotifyWidgetUpdate()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Theme management (TUI only).
|
||||
ec.RegisterTheme = func(name string, config extensions.ThemeColorConfig) {
|
||||
tc := func(c extensions.ThemeColor) [2]string { return [2]string{c.Light, c.Dark} }
|
||||
ui.RegisterThemeFromConfig(name,
|
||||
tc(config.Primary), tc(config.Secondary),
|
||||
tc(config.Success), tc(config.Warning),
|
||||
tc(config.Error), tc(config.Info),
|
||||
tc(config.Text), tc(config.Muted),
|
||||
tc(config.VeryMuted), tc(config.Background),
|
||||
tc(config.Border), tc(config.MutedBorder),
|
||||
tc(config.System), tc(config.Tool),
|
||||
tc(config.Accent), tc(config.Highlight),
|
||||
tc(config.MdHeading), tc(config.MdLink),
|
||||
tc(config.MdKeyword), tc(config.MdString),
|
||||
tc(config.MdNumber), tc(config.MdComment),
|
||||
)
|
||||
}
|
||||
ec.SetTheme = func(name string) error {
|
||||
return ui.ApplyTheme(name)
|
||||
}
|
||||
ec.ListThemes = func() []string {
|
||||
return ui.ListThemes()
|
||||
}
|
||||
|
||||
// Skill context-injection (drives a new agent turn through the TUI).
|
||||
ec.InjectSkillAsContext = func(skillName string) string {
|
||||
skills := kitInstance.DiscoverSkillsForExtension()
|
||||
for _, s := range skills {
|
||||
if s.Name == skillName {
|
||||
appInstance.Run(fmt.Sprintf("<skill name=%q>\n%s\n</skill>", s.Name, s.Content))
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("skill not found: %s", skillName)
|
||||
}
|
||||
ec.InjectRawSkillAsContext = func(path string) string {
|
||||
s, err := kitInstance.LoadSkillForExtension(path)
|
||||
if err != "" {
|
||||
return err
|
||||
}
|
||||
appInstance.Run(fmt.Sprintf("<skill name=%q>\n%s\n</skill>", s.Name, s.Content))
|
||||
return ""
|
||||
}
|
||||
|
||||
return ec
|
||||
}
|
||||
+341
-892
File diff suppressed because it is too large
Load Diff
@@ -58,6 +58,7 @@ kit install github.com/mark3labs/kit/examples/extensions --local
|
||||
| `project-rules.go` | Project-specific rules | Session data, file reading |
|
||||
| `protected-paths.go` | Block dangerous operations | `OnToolCall` with blocking |
|
||||
| `permission-gate.go` | Confirm destructive actions | `OnToolCall` with confirmation |
|
||||
| `usage-budget.go` | Soft cost cap + per-turn report | `OnLLMUsage`, `SetState`/`GetState`, enriched `AgentEndEvent` |
|
||||
|
||||
### Tools & Commands
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
// without panicking and properly guards nil ctx calls.
|
||||
func TestSubagentMonitor_SessionStart(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("../../.kit/extensions/subagent-monitor.go")
|
||||
harness.LoadFile("./subagent-monitor.go")
|
||||
|
||||
// Emit SessionStart - should not panic even with nil ctx functions
|
||||
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test-session"})
|
||||
@@ -26,7 +26,7 @@ func TestSubagentMonitor_SessionStart(t *testing.T) {
|
||||
// creates entries and emits widget updates.
|
||||
func TestSubagentMonitor_SubagentLifecycle(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("../../.kit/extensions/subagent-monitor.go")
|
||||
harness.LoadFile("./subagent-monitor.go")
|
||||
|
||||
// Start session
|
||||
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test-session"})
|
||||
@@ -84,7 +84,7 @@ func TestSubagentMonitor_SubagentLifecycle(t *testing.T) {
|
||||
// TestSubagentMonitor_MultipleSubagents verifies multiple parallel subagents.
|
||||
func TestSubagentMonitor_MultipleSubagents(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("../../.kit/extensions/subagent-monitor.go")
|
||||
harness.LoadFile("./subagent-monitor.go")
|
||||
|
||||
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test-session"})
|
||||
if err != nil {
|
||||
@@ -130,11 +130,63 @@ func TestSubagentMonitor_MultipleSubagents(t *testing.T) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
// TestSubagentMonitor_ConcurrentSubagents verifies no panics when multiple
|
||||
// subagents emit events concurrently from different goroutines.
|
||||
func TestSubagentMonitor_ConcurrentSubagents(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("./subagent-monitor.go")
|
||||
|
||||
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test-session"})
|
||||
if err != nil {
|
||||
t.Fatalf("SessionStart should not error: %v", err)
|
||||
}
|
||||
|
||||
// Start 5 subagents concurrently
|
||||
done := make(chan struct{}, 5)
|
||||
for i := range 5 {
|
||||
go func(idx int) {
|
||||
defer func() { done <- struct{}{} }()
|
||||
|
||||
callID := fmt.Sprintf("concurrent-%d", idx)
|
||||
task := fmt.Sprintf("concurrent task %d", idx)
|
||||
|
||||
_, _ = harness.Emit(extensions.SubagentStartEvent{
|
||||
ToolCallID: callID,
|
||||
Task: task,
|
||||
})
|
||||
|
||||
// Emit many chunks rapidly
|
||||
for j := range 20 {
|
||||
_, _ = harness.Emit(extensions.SubagentChunkEvent{
|
||||
ToolCallID: callID,
|
||||
Task: task,
|
||||
ChunkType: "text",
|
||||
Content: fmt.Sprintf("agent %d chunk %d", idx, j),
|
||||
})
|
||||
}
|
||||
|
||||
_, _ = harness.Emit(extensions.SubagentEndEvent{
|
||||
ToolCallID: callID,
|
||||
Task: task,
|
||||
Response: "done",
|
||||
})
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all goroutines
|
||||
for range 5 {
|
||||
<-done
|
||||
}
|
||||
|
||||
// Allow any final processing
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
|
||||
// TestSubagentMonitor_SessionShutdown verifies shutdown doesn't panic
|
||||
// even with nil ctx functions.
|
||||
func TestSubagentMonitor_SessionShutdown(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("../../.kit/extensions/subagent-monitor.go")
|
||||
harness.LoadFile("./subagent-monitor.go")
|
||||
|
||||
// Start then shutdown
|
||||
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test-session"})
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//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] + "..."
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init demonstrates the three primitives added in issue #53:
|
||||
//
|
||||
// 1. api.OnLLMUsage(...) — per-LLM-call usage callback with token + cost
|
||||
// deltas. Use this for budget enforcement that reacts between calls
|
||||
// within a single agent turn, rather than only at turn boundaries.
|
||||
//
|
||||
// 2. ctx.SetState / ctx.GetState / ctx.DeleteState / ctx.ListState —
|
||||
// last-write-wins, session-scoped key-value store backed by a sidecar
|
||||
// file. Use this for snapshot state (current value of X) instead of
|
||||
// ctx.AppendEntry, which is append-only and bloats branch reads.
|
||||
//
|
||||
// 3. ext.AgentEndEvent.ToolCallCount / .ToolNames / .LLMCallCount /
|
||||
// .InputTokensDelta / .OutputTokensDelta / .CostDelta / .DurationMs —
|
||||
// per-turn aggregates so observer extensions don't need to maintain
|
||||
// parallel bookkeeping.
|
||||
//
|
||||
// Together these support a simple soft-budget cap: warn when the
|
||||
// cumulative cost in this session exceeds a threshold, and print a
|
||||
// per-turn report on AgentEnd.
|
||||
//
|
||||
// Usage: kit -e examples/extensions/usage-budget.go
|
||||
func Init(api ext.API) {
|
||||
const warnAtKey = "usage-budget:warn-at-usd"
|
||||
|
||||
// 1. Print per-LLM-call usage with provider, model, and cost.
|
||||
api.OnLLMUsage(func(e ext.LLMUsageEvent, ctx ext.Context) {
|
||||
ctx.Print(fmt.Sprintf(
|
||||
"[usage] step=%d %s/%s tokens=↑%d ↓%d cache=↑%d/↓%d cost=$%.4f (%s)",
|
||||
e.StepNumber, e.Provider, e.Model,
|
||||
e.InputTokens, e.OutputTokens,
|
||||
e.CacheWriteTokens, e.CacheReadTokens,
|
||||
e.Cost, e.FinishReason,
|
||||
))
|
||||
|
||||
// 2. Persist running total in last-write-wins state.
|
||||
current := 0.0
|
||||
if raw, ok := ctx.GetState("usage-budget:total-cost"); ok {
|
||||
current, _ = strconv.ParseFloat(raw, 64)
|
||||
}
|
||||
current += e.Cost
|
||||
ctx.SetState("usage-budget:total-cost", strconv.FormatFloat(current, 'f', 6, 64))
|
||||
|
||||
// Soft warn-at threshold (configurable via state).
|
||||
warnAt := 0.50
|
||||
if raw, ok := ctx.GetState(warnAtKey); ok {
|
||||
if v, err := strconv.ParseFloat(raw, 64); err == nil {
|
||||
warnAt = v
|
||||
}
|
||||
}
|
||||
if current > warnAt {
|
||||
ctx.PrintError(fmt.Sprintf(
|
||||
"[usage] session cost $%.4f exceeds soft cap $%.2f",
|
||||
current, warnAt,
|
||||
))
|
||||
}
|
||||
})
|
||||
|
||||
// 3. Print a per-turn summary using the enriched AgentEndEvent.
|
||||
api.OnAgentEnd(func(e ext.AgentEndEvent, ctx ext.Context) {
|
||||
ctx.Print(fmt.Sprintf(
|
||||
"[turn] stop=%s tools=%d llm-calls=%d tokens=↑%d ↓%d cost=$%.4f duration=%dms",
|
||||
e.StopReason, e.ToolCallCount, e.LLMCallCount,
|
||||
e.InputTokensDelta, e.OutputTokensDelta, e.CostDelta, e.DurationMs,
|
||||
))
|
||||
if len(e.ToolNames) > 0 {
|
||||
ctx.Print(fmt.Sprintf("[turn] tool order: %v", e.ToolNames))
|
||||
}
|
||||
})
|
||||
|
||||
// Bootstrap default soft cap once per session.
|
||||
api.OnSessionStart(func(e ext.SessionStartEvent, ctx ext.Context) {
|
||||
if _, ok := ctx.GetState(warnAtKey); !ok {
|
||||
ctx.SetState(warnAtKey, "0.50")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -42,4 +42,14 @@ defer host.Close()
|
||||
response, err := host.Prompt(ctx, "Hello!")
|
||||
```
|
||||
|
||||
Or use the functional-options constructor for quick setups (streaming defaults on):
|
||||
|
||||
```go
|
||||
host, err := kit.NewAgent(ctx,
|
||||
kit.WithModel("anthropic/claude-sonnet-4-5-20250929"),
|
||||
kit.WithSystemPrompt("You are a helpful assistant."),
|
||||
kit.Ephemeral(),
|
||||
)
|
||||
```
|
||||
|
||||
See the [SDK README](../../pkg/kit/README.md) for the full API reference.
|
||||
|
||||
@@ -62,7 +62,7 @@ func main() {
|
||||
}
|
||||
})
|
||||
// Subscribe to streaming chunks.
|
||||
host3.OnStreaming(func(e kit.MessageUpdateEvent) {
|
||||
host3.OnMessageUpdate(func(e kit.MessageUpdateEvent) {
|
||||
fmt.Print(e.Chunk)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,32 +1,34 @@
|
||||
module github.com/mark3labs/kit
|
||||
|
||||
go 1.26.2
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.1.0
|
||||
charm.land/bubbletea/v2 v2.0.5
|
||||
charm.land/fantasy v0.17.2
|
||||
charm.land/bubbletea/v2 v2.0.7
|
||||
charm.land/fantasy v0.25.0
|
||||
charm.land/huh/v2 v2.0.3
|
||||
charm.land/lipgloss/v2 v2.0.3
|
||||
github.com/alecthomas/chroma/v2 v2.23.1
|
||||
github.com/alecthomas/chroma/v2 v2.26.1
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/aymanbagabas/go-udiff v0.4.1
|
||||
github.com/charmbracelet/colorprofile v0.4.3
|
||||
github.com/charmbracelet/fang v1.0.0
|
||||
github.com/charmbracelet/log v1.0.0
|
||||
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260414011438-8c69ec811b1e
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260601155805-6cf7526a1b3f
|
||||
github.com/charmbracelet/x/editor v0.2.0
|
||||
github.com/clipperhouse/displaywidth v0.11.0
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0
|
||||
github.com/coder/acp-go-sdk v0.6.3
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/coder/acp-go-sdk v0.13.5
|
||||
github.com/fsnotify/fsnotify v1.10.1
|
||||
github.com/indaco/herald v0.13.0
|
||||
github.com/indaco/herald-md v0.3.0
|
||||
github.com/mark3labs/mcp-go v0.48.0
|
||||
github.com/mark3labs/mcp-go v0.54.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/traefik/yaegi v0.16.1
|
||||
golang.org/x/term v0.42.0
|
||||
golang.org/x/image v0.41.0
|
||||
golang.org/x/term v0.43.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -35,68 +37,69 @@ require (
|
||||
cloud.google.com/go/auth v0.20.0 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.14 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
|
||||
github.com/aws/smithy-go v1.24.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.24 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.24 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.24 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.25 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.24 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.1.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.2 // indirect
|
||||
github.com/aws/smithy-go v1.26.0 // indirect
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260413165052-6921c759c913 // indirect
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260602025833-85a30b5e440a // indirect
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260413165052-6921c759c913 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260527151214-009e6338d40d // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.1.0 // indirect
|
||||
github.com/charmbracelet/x/json v0.2.0 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/dlclark/regexp2 v1.12.0 // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686 // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.1 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||
github.com/google/jsonschema-go v0.4.3 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.21.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.16 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/kaptinlin/go-i18n v0.4.0 // indirect
|
||||
github.com/kaptinlin/jsonpointer v0.4.17 // indirect
|
||||
github.com/kaptinlin/jsonschema v0.7.7 // indirect
|
||||
github.com/kaptinlin/messageformat-go v0.4.20 // indirect
|
||||
github.com/kaptinlin/go-i18n v0.4.5 // indirect
|
||||
github.com/kaptinlin/jsonpointer v0.4.25 // indirect
|
||||
github.com/kaptinlin/jsonschema v0.7.13 // indirect
|
||||
github.com/kaptinlin/messageformat-go v0.6.0 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/muesli/mango v0.2.0 // indirect
|
||||
github.com/muesli/mango-cobra v1.3.0 // indirect
|
||||
github.com/muesli/mango-pflag v0.2.0 // indirect
|
||||
github.com/muesli/roff v0.1.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/gjson v1.19.0 // indirect
|
||||
github.com/tidwall/match v1.2.0 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
@@ -104,21 +107,21 @@ require (
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
github.com/yuin/goldmark v1.8.2 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
|
||||
go.opentelemetry.io/otel v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
|
||||
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/crypto v0.52.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260603202125-055de637280b // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
google.golang.org/api v0.275.0 // indirect
|
||||
google.golang.org/genai v1.54.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
|
||||
google.golang.org/grpc v1.80.0 // indirect
|
||||
google.golang.org/api v0.282.0 // indirect
|
||||
google.golang.org/genai v1.58.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/grpc v1.81.1 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
)
|
||||
@@ -129,13 +132,13 @@ require (
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.24 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/spf13/pflag v1.0.10
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0
|
||||
)
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
|
||||
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
|
||||
charm.land/bubbletea/v2 v2.0.5 h1:TQlLFqxo39AAHSVuOhJ5D3nH7O9Nk8JGinsfWQ4y1U4=
|
||||
charm.land/bubbletea/v2 v2.0.5/go.mod h1:dvbsYZD+MHkdIZl+Z67D212hEvB+GII2tfH8f9SnoDw=
|
||||
charm.land/fantasy v0.17.2 h1:ojTMufMxY/PVH7TzYUxht2SVkvD90iCTJfmPR6c8BR8=
|
||||
charm.land/fantasy v0.17.2/go.mod h1:V9cCIUMZB9g3Bq40aKEY8xBNzDd48EdfHp2OMS0uzWs=
|
||||
charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0=
|
||||
charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs=
|
||||
charm.land/fantasy v0.25.0 h1:oXOWY1ivmTSnhYGzAolscF8zKtavWZyBWv0LHRSwN5Q=
|
||||
charm.land/fantasy v0.25.0/go.mod h1:8QrWUzIcKwZQP+aAnC9vLu3iID6hu9/Jt+rPMiieBkc=
|
||||
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
|
||||
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
|
||||
charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
|
||||
charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
|
||||
charm.land/x/vcr v0.1.1/go.mod h1:eByq2gqzWvcct/8XE2XO5KznoWEBiXH56+y2gphbltM=
|
||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
|
||||
@@ -16,60 +18,81 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=
|
||||
cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4=
|
||||
cloud.google.com/go/longrunning v0.5.6/go.mod h1:vUaDrWYOMKRuhiv6JBnn49YxCPz2Ayn9GqyjaBT8/mA=
|
||||
cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM=
|
||||
cloud.google.com/go/storage v1.62.1/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA=
|
||||
cloud.google.com/go/translate v1.10.3/go.mod h1:GW0vC1qvPtd3pgtypCv4k4U8B7EdgK9/QEF2aJEUovs=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0/go.mod h1:hEpiGU18xf70qb3jbTcIggWAiEfX/cOIVc2OTe4OegA=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0/go.mod h1:6ZZMQhZKDvUvkJw2rc+oDP90tMMzuU/J+5HG1ZmPOmE=
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/Rhymond/go-money v1.0.15/go.mod h1:iHvCuIvitxu2JIlAlhF0g9jHqjRSr+rpdOs7Omqlupg=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
|
||||
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
|
||||
github.com/alecthomas/chroma/v2 v2.26.1 h1:2X21EdxGZNv5GF9mG5u+uzc02GCFyGxbcBm3Grd9A78=
|
||||
github.com/alecthomas/chroma/v2 v2.26.1/go.mod h1:lxhRRa9H4hPmRLOOdYga4zkQIQjq3dtrrdwQeCfu78Y=
|
||||
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/ardanlabs/jinja v1.2.0/go.mod h1:aXXzlJfjA+T3XNKA/YT5ZtDq2VJxt5a5siZ8cl9B35Q=
|
||||
github.com/ardanlabs/kronk v1.25.2/go.mod h1:b5Gg4jDqvHDklkeHNB8+7treZRxUiCFsV65zphrTloY=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw=
|
||||
github.com/aws/smithy-go v1.24.3 h1:XgOAaUgx+HhVBoP4v8n6HCQoTRDhoMghKqw4LNHsDNg=
|
||||
github.com/aws/smithy-go v1.24.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.8 h1:sRs7nG6/RiEBZ/K5UO2sNw0w40U02Nmz1VtARloTZXk=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.8/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.19 h1:qRhIJMbevHUvIE7X4TK8N8zye5+5AhapcslPrvB+qKE=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.19/go.mod h1:RbJ24nfoya63+Mf5VI+CGCGk9vEdv28xPeii+gojRYs=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.18 h1:GcXQz2M/0ZvMo0v5DakUqbDBeBM1ZNaivkolEF4Esgw=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.18/go.mod h1:sHJ06tMGcD3ZpmMyJqV+VBsGilhSIZPIN+ZFy5Dg0C4=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.24 h1:FQm5ApnyzkuJdXLGskPce83CK1CQKC4RUnIHKVe4BU4=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.24/go.mod h1:JsC7dqQc55MlZ5mvNsDMMge71u8pVcSzU3RNz2h/5yQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.24 h1:u6kJU2i0va1AgtJsH3RdWKWqHULlTh7zHwb35Womf74=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.24/go.mod h1:7GY+xLcXOFUpCkNwDReft9qOAVg54A4/AnjHIU7sSAY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.24 h1:Xhbcf3KugX6vX7SDyUK205Oicyfg7EGuvoVNyP5L6DM=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.24/go.mod h1:rwDgb2HNOGZsnTHylOUedM7Vnl+bCfnXDqUNPsFWYfk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.25 h1:54CTMmlJ71Rk2dYvM9qZOob+39wjlVja2zDLxCu69Ew=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.25/go.mod h1:BZaHqxsS9vN1fvV5EfEl0OBLOk5+AajWsMu6MjqnZB4=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.24 h1:CQW2FTrflfoslYWLf3fv7vG28Q219+v8YJS5QTQb2+Y=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.24/go.mod h1:Xfx13T+u3nH6EEzgl9fBSO6nDRmze1FvnZNYkctQ2zw=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.1.0 h1:yQo3eZ5qFaL1sJWqs1nL6j3yPHA2/R7c6tQ4T+0IO10=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.1.0/go.mod h1:3Zzou41Qt/ueXfIzHvTEjDNuR5IjCUBVF01SNhrt1e8=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.18 h1:ApLTFdAZfDhZSiY5uskwECKHkSNNF83y2Ru2r7SezWA=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.18/go.mod h1:A9K9qx2l6nK89hp+a350FdGfRkrkH5HdiEjHbiy/Q/c=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.1 h1:4VD7TIZOGzehrgQ8vDE+1c6BQW4ErZPGY8ohZT5LXEE=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.1/go.mod h1:er0SFJfdV89Rit5hIJu/EXtv+qC2XMnxoksLmcUFkqM=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.2 h1:XKnxlM4KZH1gktcsh3zSWc7GW4KivEv/OkifmHOhCUY=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.2/go.mod h1:KJYmkQaFB3SUW2j3aBkPsxNmAb4ZsSOvbvCpuxzHJA0=
|
||||
github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s=
|
||||
github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4=
|
||||
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab h1:J7XQLgl9sefgTnTGrmX3xqvp5o6MCiBzEjGv5igAlc4=
|
||||
@@ -86,8 +109,8 @@ github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdR
|
||||
github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA=
|
||||
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266 h1:BW/sZtyd1JyYy0h5adMm3tzpNyL857LWjuTRET6OhpY=
|
||||
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266/go.mod h1:1DahUaExbUZx/jD+FNT2PKP4L9rLE5+ZBRuI8mZjd/E=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260414011438-8c69ec811b1e h1:O5hZFj55wZQWxMiRtQLa3uLKhZGZGS/j8M3OXinQlrw=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260414011438-8c69ec811b1e/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260601155805-6cf7526a1b3f h1:vKsPSlO4g4jKfJ9enESgNZ45BkbHngTIq3UxNOzic74=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260601155805-6cf7526a1b3f/go.mod h1:hFpumms29Smx3LStRfku8vcCTBe1Kq8aCXtHUJa3mjY=
|
||||
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
||||
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
@@ -98,14 +121,14 @@ github.com/charmbracelet/x/editor v0.2.0 h1:7XLUKtaRaB8jN7bWU2p2UChiySyaAuIfYiIR
|
||||
github.com/charmbracelet/x/editor v0.2.0/go.mod h1:p3oQ28TSL3YPd+GKJ1fHWcp+7bVGpedHpXmo0D6t1dY=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260413165052-6921c759c913 h1:6F/6bu5nBLjodsvaU5xAszTaxtHrDU5UiJarpMPZj48=
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260413165052-6921c759c913/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260602025833-85a30b5e440a h1:aVvnksCVgxB2igk7jERL9ARIkbDXccp1gXCFqhGlamQ=
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260602025833-85a30b5e440a/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260413165052-6921c759c913 h1:RiZFY92Ug9iz1CenzxSSQla2Z3WflsR7bIuXq40JlpU=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260413165052-6921c759c913/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260527151214-009e6338d40d h1:RxcAR+vJCoD8QqT1cqLtkQKw+1cqvjqnu5IpPqYzPco=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260527151214-009e6338d40d/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
|
||||
github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA=
|
||||
github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
|
||||
github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
|
||||
@@ -120,36 +143,45 @@ github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcO
|
||||
github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
|
||||
github.com/coder/acp-go-sdk v0.6.3 h1:LsXQytehdjKIYJnoVWON/nf7mqbiarnyuyE3rrjBsXQ=
|
||||
github.com/coder/acp-go-sdk v0.6.3/go.mod h1:yKzM/3R9uELp4+nBAwwtkS0aN1FOFjo11CNPy37yFko=
|
||||
github.com/coder/acp-go-sdk v0.13.5 h1:LI9jq5xon7xslaYlnoktvTVyDlE37yIk2daT7N9ASYk=
|
||||
github.com/coder/acp-go-sdk v0.13.5/go.mod h1:yKzM/3R9uELp4+nBAwwtkS0aN1FOFjo11CNPy37yFko=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dlclark/regexp2/v2 v2.1.1 h1:LCUGyd9Wf+r+VVOl8Ny38JTpWJcAsdVnCIuhhtthmKw=
|
||||
github.com/dlclark/regexp2/v2 v2.1.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
|
||||
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
||||
github.com/dromara/carbon/v2 v2.6.16/go.mod h1:NGo3reeV5vhWCYWcSqbJRZm46MEwyfYI5EJRdVFoLJo=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/eliben/go-sentencepiece v0.6.0/go.mod h1:nNYk4aMzgBoI6QFp4LUG8Eu1uO9fHD9L5ZEre93o9+c=
|
||||
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
|
||||
github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
|
||||
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
|
||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686 h1:NZBJxCpbHS1gzS6xAmyxbJznosZIIPk9IB42v62UvKA=
|
||||
github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
|
||||
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
@@ -163,38 +195,53 @@ github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/google/go-pkcs11 v0.3.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY=
|
||||
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
|
||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
|
||||
github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI=
|
||||
github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE=
|
||||
github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4=
|
||||
github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
|
||||
github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.72/go.mod h1:Vn+BBgKQHVQYdVQ4NZDICE1Brb+JfaONyDHr3q07oQc=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/hashicorp/go-getter v1.8.6/go.mod h1:nVH12eOV2P58dIiL3rsU6Fh3wLeJEKBOJzhMmzlSWoo=
|
||||
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/hybridgroup/yzma v1.13.0/go.mod h1:zrzMgv/KVQz23+s6l16b+vJ+9uJVBdWtGcGkwRTMeiQ=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/indaco/herald v0.13.0 h1:+xVG9Fx5NpuWhwku/9IlRL6I009NnX4VUGKvlZHTRxU=
|
||||
github.com/indaco/herald v0.13.0/go.mod h1:T5g1+XLYvpjouhzAGHnAHDCKizhESkoV6+QPZ3DhgWA=
|
||||
github.com/indaco/herald-md v0.3.0 h1:hN1cKyrexPPM9PeHBsKuaWvIizSi/iYvM9yzRgtdb8M=
|
||||
github.com/indaco/herald-md v0.3.0/go.mod h1:RUHVaDSG45ymJjKyxpDwBocLXrZo93FB4OeYMsw9B9s=
|
||||
github.com/kaptinlin/go-i18n v0.4.0 h1:i7L3U2yurg+xhokITtJ0k+mjHnXqkoyz8ju5Wb7W8Oc=
|
||||
github.com/kaptinlin/go-i18n v0.4.0/go.mod h1:njA6x0+4MWGcLWT0KLrwekhRPmze1Hnstf2+VJFzwpM=
|
||||
github.com/kaptinlin/jsonpointer v0.4.17 h1:mY9k8ciWncxbsECyaxKnR0MdmxamNdp2tLQkAKVrtSk=
|
||||
github.com/kaptinlin/jsonpointer v0.4.17/go.mod h1:SsfsjqnHG5zuKo1DTBzk1VknaHlL4osHw+X9kZKukpU=
|
||||
github.com/kaptinlin/jsonschema v0.7.7 h1:41BlQJ9dskH0oE5DSzBUrl/w4JQYIr6N6L0B5GNyDoM=
|
||||
github.com/kaptinlin/jsonschema v0.7.7/go.mod h1:rKjWfyySHSxAD7Li2ctYkPlOu960igoKBvZ2ADRtd5Q=
|
||||
github.com/kaptinlin/messageformat-go v0.4.20 h1:a0ufTd5liiUubIGeGxpSTnNS8ZSrN4DV01/wGFmfzMs=
|
||||
github.com/kaptinlin/messageformat-go v0.4.20/go.mod h1:FqdEPfQLkqVBX7OBRMPgYwUPvKYJohFD9Ok1BMzCfIo=
|
||||
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/jupiterrider/ffi v0.7.0/go.mod h1:9dauhpOfNqrqk28fxuu0kkdeFtT9Qr4vbfigiuIXN7c=
|
||||
github.com/kaptinlin/go-i18n v0.4.5 h1:9tIlo5A0RXth+yZJO2MG7Bhpu/X9PlzQnGz/qyYWNoY=
|
||||
github.com/kaptinlin/go-i18n v0.4.5/go.mod h1:mU/7BH4molY5lGZYBwBRKAaiJ70dWRHuqmQ0/pFLGno=
|
||||
github.com/kaptinlin/jsonpointer v0.4.25 h1:iJ197e8n+WwqaqBsa53FqG3rPJCg5oijyFXEXNWWC3E=
|
||||
github.com/kaptinlin/jsonpointer v0.4.25/go.mod h1:wVOBaXGGnP42YsMb6zev/3W5POTvspdNfh8DXzf8XS8=
|
||||
github.com/kaptinlin/jsonschema v0.7.13 h1:kahVXTy/rURL0XJjyQ9WELm59wEmXi6IY0TWswQEFvU=
|
||||
github.com/kaptinlin/jsonschema v0.7.13/go.mod h1:Uh0aUBusnhXDCEXJ2oimL/hx7YTo7F+sKniE+tM0ERc=
|
||||
github.com/kaptinlin/messageformat-go v0.6.0 h1:D6jiXFsKW4/JG2CMddv/F6Rev9KVbCRKEzzV5QOAcpc=
|
||||
github.com/kaptinlin/messageformat-go v0.6.0/go.mod h1:NKjwS6e9u7DRhAK+vydjDDwJ7UbdHhYjk/yk2WPuZPs=
|
||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
@@ -203,12 +250,14 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mark3labs/mcp-go v0.48.0 h1:o+MXuGW/HCeR2ny5LcAcZQn2bo6I2xaZMEHnpRG+dtw=
|
||||
github.com/mark3labs/mcp-go v0.48.0/go.mod h1:JKTC7R2LLVagkEWK7Kwu7DbmA6iIvnNAod6yrHiQMag=
|
||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
||||
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mark3labs/mcp-go v0.54.1 h1:Ap/ptEB9FtWzFKM8NDsTA7QDxerQOC06eZigrTldVj0=
|
||||
github.com/mark3labs/mcp-go v0.54.1/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
|
||||
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
@@ -223,14 +272,19 @@ github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
|
||||
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
@@ -238,6 +292,10 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
@@ -249,13 +307,14 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
|
||||
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
|
||||
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
@@ -266,67 +325,87 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/traefik/yaegi v0.16.1 h1:f1De3DVJqIDKmnasUF6MwmWv1dSEEat0wcpXhD2On3E=
|
||||
github.com/traefik/yaegi v0.16.1/go.mod h1:4eVhbPb3LnD2VigQjhYbEJ69vDRFdT2HQNrXx8eEwUY=
|
||||
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 h1:0Qx7VGBacMm9ZENQ7TnNObTYI4ShC+lHI16seduaxZo=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0/go.mod h1:Sje3i3MjSPKTSPvVWCaL8ugBzJwik3u4smCjUeuupqg=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 h1:2yEATaop1/a1I4psnSLgWVPLWwCzkqWakgJy7xTDVy0=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/exp v0.0.0-20260603202125-055de637280b h1:v1uXiEBHo8QA0LiGCo7UgHMzHT4Kdfpl2zmtH5vaP1Q=
|
||||
golang.org/x/exp v0.0.0-20260603202125-055de637280b/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
|
||||
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
|
||||
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
|
||||
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/api v0.275.0 h1:vfY5d9vFVJeWEZT65QDd9hbndr7FyZ2+6mIzGAh71NI=
|
||||
google.golang.org/api v0.275.0/go.mod h1:Fnag/EWUPIcJXuIkP1pjoTgS5vdxlk3eeemL7Do6bvw=
|
||||
google.golang.org/genai v1.54.0 h1:ZQCa70WMTJDI11FdqWCzGvZ5PanpcpfoO6jl/lrSnGU=
|
||||
google.golang.org/genai v1.54.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
|
||||
google.golang.org/genproto v0.0.0-20260406210006-6f92a3bedf2d h1:N1Ec54vZnIPd7MnxRiYLW+oY4fDR4BOS/LrssdD9+ek=
|
||||
google.golang.org/genproto v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:c2hJ1grtnH0xUiEKGDGkjGNTJ1Hy2LrblyKOHF0sqRM=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d h1:/aDRtSZJjyLQzm75d+a1wOJaqyKBMvIAfeQmoa3ORiI=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:etfGUgejTiadZAUaEP14NP97xi1RGeawqkjDARA/UOs=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/api v0.282.0 h1:WmJiSVqUnKqJCpJOx7YADbXaC+9DDsnGSfllFSj7R2I=
|
||||
google.golang.org/api v0.282.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM=
|
||||
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
|
||||
google.golang.org/genai v1.58.0 h1:MNA3ZkRyr7MnRwZ9RNZ60p4+UMKV3yYRw6pyHq4pp0U=
|
||||
google.golang.org/genai v1.58.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
|
||||
google.golang.org/genproto v0.0.0-20260504160031-60b97b32f348 h1:JjVGDZYWkJWZcxveJGzfkXC5myDVWAd4dZdgbzrDUv8=
|
||||
google.golang.org/genproto v0.0.0-20260504160031-60b97b32f348/go.mod h1:95PqD4xM+AdOcBGsmgfaofXsiA37uXDtDufVbntT3TU=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260504160031-60b97b32f348 h1:U8orV30l6KpDsi9dxU0CoJZGbjS8EEpw+6ba+XwGPQA=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260504160031-60b97b32f348/go.mod h1:Yzdzr5OOZFgSsEV2D/Xi9NL3bszpXFAg0hFJiRohcD8=
|
||||
google.golang.org/genproto/googleapis/bytestream v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:6TABGosqSqU2l1+fJ3jdvOYPPVryeKybxYF0cCZkTBE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
||||
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290/go.mod h1:sbq5oMEcM4PXngbcNbHhzfCP9OdZodLhrbRYoyg09HY=
|
||||
gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -61,6 +61,12 @@ func (a *Agent) Authenticate(_ context.Context, _ acp.AuthenticateRequest) (acp.
|
||||
return acp.AuthenticateResponse{}, nil
|
||||
}
|
||||
|
||||
// Logout handles logout requests. Kit doesn't require auth for local stdio
|
||||
// usage, so this is a no-op.
|
||||
func (a *Agent) Logout(_ context.Context, _ acp.LogoutRequest) (acp.LogoutResponse, error) {
|
||||
return acp.LogoutResponse{}, nil
|
||||
}
|
||||
|
||||
// Initialize negotiates capabilities with the ACP client.
|
||||
func (a *Agent) Initialize(_ context.Context, params acp.InitializeRequest) (acp.InitializeResponse, error) {
|
||||
log.Debug("acp: initialize", "protocol_version", params.ProtocolVersion)
|
||||
@@ -177,22 +183,75 @@ func (a *Agent) SetSessionMode(_ context.Context, _ acp.SetSessionModeRequest) (
|
||||
return acp.SetSessionModeResponse{}, nil
|
||||
}
|
||||
|
||||
// SetSessionModel changes the active model for a session.
|
||||
func (a *Agent) SetSessionModel(ctx context.Context, params acp.SetSessionModelRequest) (acp.SetSessionModelResponse, error) {
|
||||
// ListSessions returns an empty session list. Kit doesn't persist sessions
|
||||
// across restarts in ACP mode, so this is effectively a no-op.
|
||||
func (a *Agent) ListSessions(_ context.Context, _ acp.ListSessionsRequest) (acp.ListSessionsResponse, error) {
|
||||
return acp.ListSessionsResponse{
|
||||
Sessions: []acp.SessionInfo{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CloseSession cancels any ongoing work for the session and frees its resources.
|
||||
func (a *Agent) CloseSession(_ context.Context, params acp.CloseSessionRequest) (acp.CloseSessionResponse, error) {
|
||||
sessionID := string(params.SessionId)
|
||||
sess, ok := a.registry.get(sessionID)
|
||||
if !ok {
|
||||
return acp.SetSessionModelResponse{}, acp.NewInvalidParams(fmt.Sprintf("session not found: %s", sessionID))
|
||||
return acp.CloseSessionResponse{}, nil
|
||||
}
|
||||
|
||||
modelID := string(params.ModelId)
|
||||
log.Debug("acp: set_session_model", "session", sessionID, "model", modelID)
|
||||
log.Debug("acp: close session", "session", sessionID)
|
||||
sess.cancelPrompt()
|
||||
a.registry.remove(sessionID)
|
||||
return acp.CloseSessionResponse{}, nil
|
||||
}
|
||||
|
||||
if err := sess.kit.SetModel(ctx, modelID); err != nil {
|
||||
return acp.SetSessionModelResponse{}, fmt.Errorf("set model: %w", err)
|
||||
// ResumeSession is not supported — Kit doesn't persist sessions across
|
||||
// restarts in ACP mode. Clients should use NewSession instead.
|
||||
func (a *Agent) ResumeSession(_ context.Context, _ acp.ResumeSessionRequest) (acp.ResumeSessionResponse, error) {
|
||||
return acp.ResumeSessionResponse{}, fmt.Errorf("resume session not supported")
|
||||
}
|
||||
|
||||
// SetSessionConfigOption handles session configuration changes. Currently
|
||||
// supports the "model" config option to change the active model for a session.
|
||||
func (a *Agent) SetSessionConfigOption(ctx context.Context, params acp.SetSessionConfigOptionRequest) (acp.SetSessionConfigOptionResponse, error) {
|
||||
// Extract session ID and config ID from whichever variant is present.
|
||||
var sessionID string
|
||||
var configID string
|
||||
var value string
|
||||
|
||||
switch {
|
||||
case params.ValueId != nil:
|
||||
sessionID = string(params.ValueId.SessionId)
|
||||
configID = string(params.ValueId.ConfigId)
|
||||
value = string(params.ValueId.Value)
|
||||
case params.Boolean != nil:
|
||||
sessionID = string(params.Boolean.SessionId)
|
||||
configID = string(params.Boolean.ConfigId)
|
||||
// Boolean config options are not used for model selection.
|
||||
log.Debug("acp: set_session_config_option (boolean)", "session", sessionID, "config", configID, "value", params.Boolean.Value)
|
||||
return acp.SetSessionConfigOptionResponse{}, nil
|
||||
default:
|
||||
return acp.SetSessionConfigOptionResponse{}, acp.NewInvalidParams("unsupported config option variant")
|
||||
}
|
||||
|
||||
return acp.SetSessionModelResponse{}, nil
|
||||
sess, ok := a.registry.get(sessionID)
|
||||
if !ok {
|
||||
return acp.SetSessionConfigOptionResponse{}, acp.NewInvalidParams(fmt.Sprintf("session not found: %s", sessionID))
|
||||
}
|
||||
|
||||
log.Debug("acp: set_session_config_option", "session", sessionID, "config", configID, "value", value)
|
||||
|
||||
// Handle known config options.
|
||||
switch configID {
|
||||
case "model":
|
||||
if err := sess.kit.SetModel(ctx, value); err != nil {
|
||||
return acp.SetSessionConfigOptionResponse{}, fmt.Errorf("set model: %w", err)
|
||||
}
|
||||
default:
|
||||
log.Debug("acp: unknown config option", "config", configID)
|
||||
}
|
||||
|
||||
return acp.SetSessionConfigOptionResponse{}, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+86
-168
@@ -7,7 +7,9 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/mark3labs/kit/internal/extbridge"
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
)
|
||||
@@ -37,10 +39,21 @@ func newSessionRegistry() *sessionRegistry {
|
||||
// given working directory. The Kit-generated session ID is used as the ACP
|
||||
// session ID so the mapping is 1:1.
|
||||
func (r *sessionRegistry) create(ctx context.Context, cwd string) (*acpSession, error) {
|
||||
// Each ACP session gets its own isolated config store (CLI is left nil) so
|
||||
// per-session SetModel / SetThinkingLevel calls cannot race or bleed across
|
||||
// the sessionRegistry. We seed the relevant root-command flag values from
|
||||
// the process-global store (which cobra populated from flags) so launching
|
||||
// `kit acp -m <model> [--thinking-level ...] [--provider-url ...]` is still
|
||||
// honored; .kit.yml and KIT_* env vars are loaded per session by kit.New.
|
||||
streamOn := true
|
||||
kitInstance, err := kit.New(ctx, &kit.Options{
|
||||
SessionDir: cwd,
|
||||
Quiet: true,
|
||||
Streaming: true,
|
||||
SessionDir: cwd,
|
||||
Quiet: true,
|
||||
Streaming: &streamOn,
|
||||
Model: viper.GetString("model"),
|
||||
ThinkingLevel: viper.GetString("thinking-level"),
|
||||
ProviderURL: viper.GetString("provider-url"),
|
||||
ProviderAPIKey: viper.GetString("provider-api-key"),
|
||||
})
|
||||
if err != nil {
|
||||
// Provide actionable guidance for provider auth errors, which are
|
||||
@@ -60,142 +73,70 @@ func (r *sessionRegistry) create(ctx context.Context, cwd string) (*acpSession,
|
||||
|
||||
// Wire extension context with headless implementations so extensions
|
||||
// work in ACP mode. TUI-dependent features (widgets, prompts, editor)
|
||||
// become no-ops or return cancelled; all data/model/tool APIs work
|
||||
// identically to interactive mode.
|
||||
// become no-ops or return cancelled; all data/model/tool APIs come from
|
||||
// extbridge.BaseContext and work identically to interactive mode.
|
||||
if kitInstance.Extensions().HasExtensions() {
|
||||
kitInstance.Extensions().SetContext(extensions.Context{
|
||||
SessionID: sessionID,
|
||||
CWD: cwd,
|
||||
Model: kitInstance.GetModelString(),
|
||||
Interactive: false,
|
||||
// Use a background context for subagent spawns: the create() ctx is
|
||||
// request-scoped and may be cancelled before extensions spawn anything.
|
||||
ec := extbridge.BaseContext(context.Background(), kitInstance)
|
||||
|
||||
// Output — route through structured logger.
|
||||
Print: func(text string) { log.Debug("extension: print", "text", text) },
|
||||
PrintInfo: func(text string) { log.Info("extension: info", "text", text) },
|
||||
PrintError: func(text string) { log.Error("extension: error", "text", text) },
|
||||
PrintBlock: func(opts extensions.PrintBlockOpts) {
|
||||
log.Info("extension: block", "subtitle", opts.Subtitle, "text", opts.Text)
|
||||
},
|
||||
ec.SessionID = sessionID
|
||||
ec.CWD = cwd
|
||||
ec.Model = kitInstance.GetModelString()
|
||||
ec.Interactive = false
|
||||
|
||||
// Message injection — no-ops for now; ACP clients drive prompts.
|
||||
SendMessage: func(string) {},
|
||||
CancelAndSend: func(string) {},
|
||||
Exit: func() {},
|
||||
// Output — route through structured logger.
|
||||
ec.Print = func(text string) { log.Debug("extension: print", "text", text) }
|
||||
ec.PrintInfo = func(text string) { log.Info("extension: info", "text", text) }
|
||||
ec.PrintError = func(text string) { log.Error("extension: error", "text", text) }
|
||||
ec.PrintBlock = func(opts extensions.PrintBlockOpts) {
|
||||
log.Info("extension: block", "subtitle", opts.Subtitle, "text", opts.Text)
|
||||
}
|
||||
|
||||
// TUI widgets/chrome — silent no-ops (no TUI in ACP).
|
||||
SetWidget: func(extensions.WidgetConfig) {},
|
||||
RemoveWidget: func(string) {},
|
||||
SetHeader: func(extensions.HeaderFooterConfig) {},
|
||||
RemoveHeader: func() {},
|
||||
SetFooter: func(extensions.HeaderFooterConfig) {},
|
||||
RemoveFooter: func() {},
|
||||
SetEditor: func(extensions.EditorConfig) {},
|
||||
ResetEditor: func() {},
|
||||
SetEditorText: func(string) {},
|
||||
SetUIVisibility: func(extensions.UIVisibility) {},
|
||||
SetStatus: func(string, string, int) {},
|
||||
RemoveStatus: func(string) {},
|
||||
// Message injection — no-ops for now; ACP clients drive prompts.
|
||||
ec.SendMessage = func(string) {}
|
||||
ec.CancelAndSend = func(string) {}
|
||||
ec.Exit = func() {}
|
||||
|
||||
// Interactive prompts — return cancelled (no user to prompt).
|
||||
PromptSelect: func(extensions.PromptSelectConfig) extensions.PromptSelectResult {
|
||||
return extensions.PromptSelectResult{Cancelled: true}
|
||||
},
|
||||
PromptConfirm: func(extensions.PromptConfirmConfig) extensions.PromptConfirmResult {
|
||||
return extensions.PromptConfirmResult{Cancelled: true}
|
||||
},
|
||||
PromptInput: func(extensions.PromptInputConfig) extensions.PromptInputResult {
|
||||
return extensions.PromptInputResult{Cancelled: true}
|
||||
},
|
||||
ShowOverlay: func(extensions.OverlayConfig) extensions.OverlayResult {
|
||||
return extensions.OverlayResult{Cancelled: true, Index: -1}
|
||||
},
|
||||
SuspendTUI: func(callback func()) error { callback(); return nil },
|
||||
// TUI widgets/chrome — silent no-ops (no TUI in ACP).
|
||||
ec.SetWidget = func(extensions.WidgetConfig) {}
|
||||
ec.RemoveWidget = func(string) {}
|
||||
ec.SetHeader = func(extensions.HeaderFooterConfig) {}
|
||||
ec.RemoveHeader = func() {}
|
||||
ec.SetFooter = func(extensions.HeaderFooterConfig) {}
|
||||
ec.RemoveFooter = func() {}
|
||||
ec.SetEditor = func(extensions.EditorConfig) {}
|
||||
ec.ResetEditor = func() {}
|
||||
ec.SetEditorText = func(string) {}
|
||||
ec.SetUIVisibility = func(extensions.UIVisibility) {}
|
||||
ec.SetStatus = func(string, string, int) {}
|
||||
ec.RemoveStatus = func(string) {}
|
||||
|
||||
// Data access — delegate to Kit instance.
|
||||
GetContextStats: func() extensions.ContextStats {
|
||||
s := kitInstance.GetContextStats()
|
||||
return extensions.ContextStats{
|
||||
EstimatedTokens: s.EstimatedTokens,
|
||||
ContextLimit: s.ContextLimit,
|
||||
UsagePercent: s.UsagePercent,
|
||||
MessageCount: s.MessageCount,
|
||||
}
|
||||
},
|
||||
GetMessages: func() []extensions.SessionMessage { return kitInstance.Extensions().GetSessionMessages() },
|
||||
GetSessionPath: func() string { return kitInstance.GetSessionPath() },
|
||||
AppendEntry: func(entryType, data string) (string, error) {
|
||||
return kitInstance.Extensions().AppendEntry(entryType, data)
|
||||
},
|
||||
GetEntries: func(entryType string) []extensions.ExtensionEntry {
|
||||
return kitInstance.Extensions().GetEntries(entryType)
|
||||
},
|
||||
// Interactive prompts — return cancelled (no user to prompt).
|
||||
ec.PromptSelect = func(extensions.PromptSelectConfig) extensions.PromptSelectResult {
|
||||
return extensions.PromptSelectResult{Cancelled: true}
|
||||
}
|
||||
ec.PromptConfirm = func(extensions.PromptConfirmConfig) extensions.PromptConfirmResult {
|
||||
return extensions.PromptConfirmResult{Cancelled: true}
|
||||
}
|
||||
ec.PromptInput = func(extensions.PromptInputConfig) extensions.PromptInputResult {
|
||||
return extensions.PromptInputResult{Cancelled: true}
|
||||
}
|
||||
ec.ShowOverlay = func(extensions.OverlayConfig) extensions.OverlayResult {
|
||||
return extensions.OverlayResult{Cancelled: true, Index: -1}
|
||||
}
|
||||
ec.SuspendTUI = func(callback func()) error { callback(); return nil }
|
||||
|
||||
// Options, model, and tool management.
|
||||
GetOption: func(name string) string { return kitInstance.Extensions().GetOption(name) },
|
||||
SetOption: func(name, value string) { kitInstance.Extensions().SetOption(name, value) },
|
||||
SetModel: func(modelString string) error {
|
||||
previousModel := kitInstance.Extensions().GetContext().Model
|
||||
if err := kitInstance.SetModel(context.Background(), modelString); err != nil {
|
||||
return err
|
||||
}
|
||||
kitInstance.Extensions().UpdateContextModel(modelString)
|
||||
kitInstance.Extensions().EmitModelChange(modelString, previousModel, "extension")
|
||||
return nil
|
||||
},
|
||||
GetAvailableModels: func() []extensions.ModelInfoEntry { return kitInstance.GetAvailableModels() },
|
||||
EmitCustomEvent: func(name, data string) { kitInstance.Extensions().EmitCustomEvent(name, data) },
|
||||
GetAllTools: func() []extensions.ToolInfo { return kitInstance.Extensions().GetToolInfos() },
|
||||
SetActiveTools: func(names []string) { kitInstance.Extensions().SetActiveTools(names) },
|
||||
// Render — fall back to logging.
|
||||
ec.RenderMessage = func(name, content string) {
|
||||
renderer := kitInstance.Extensions().GetMessageRenderer(name)
|
||||
if renderer != nil && renderer.Render != nil {
|
||||
content = renderer.Render(content, 80)
|
||||
}
|
||||
log.Info("extension: message", "renderer", name, "content", content)
|
||||
}
|
||||
|
||||
// LLM completions and subagents.
|
||||
Complete: func(req extensions.CompleteRequest) (extensions.CompleteResponse, error) {
|
||||
return kitInstance.ExecuteCompletion(context.Background(), req)
|
||||
},
|
||||
SpawnSubagent: func(config extensions.SubagentConfig) (*extensions.SubagentHandle, *extensions.SubagentResult, error) {
|
||||
sdkCfg := kit.SubagentConfig{
|
||||
Prompt: config.Prompt,
|
||||
Model: config.Model,
|
||||
SystemPrompt: config.SystemPrompt,
|
||||
Timeout: config.Timeout,
|
||||
NoSession: config.NoSession,
|
||||
}
|
||||
if config.OnEvent != nil {
|
||||
sdkCfg.OnEvent = func(e kit.Event) {
|
||||
se := sdkEventToSubagentEvent(e)
|
||||
if se.Type != "" {
|
||||
config.OnEvent(se)
|
||||
}
|
||||
}
|
||||
}
|
||||
result, err := kitInstance.Subagent(context.Background(), sdkCfg)
|
||||
if result == nil {
|
||||
return nil, &extensions.SubagentResult{Error: err}, err
|
||||
}
|
||||
extResult := &extensions.SubagentResult{
|
||||
Response: result.Response,
|
||||
Error: err,
|
||||
SessionID: result.SessionID,
|
||||
Elapsed: result.Elapsed,
|
||||
}
|
||||
if result.Usage != nil {
|
||||
extResult.Usage = &extensions.SubagentUsage{
|
||||
InputTokens: result.Usage.InputTokens,
|
||||
OutputTokens: result.Usage.OutputTokens,
|
||||
}
|
||||
}
|
||||
return nil, extResult, err
|
||||
},
|
||||
|
||||
// Render — fall back to logging.
|
||||
RenderMessage: func(name, content string) {
|
||||
renderer := kitInstance.Extensions().GetMessageRenderer(name)
|
||||
if renderer != nil && renderer.Render != nil {
|
||||
content = renderer.Render(content, 80)
|
||||
}
|
||||
log.Info("extension: message", "renderer", name, "content", content)
|
||||
},
|
||||
ReloadExtensions: func() error { return kitInstance.Extensions().Reload() },
|
||||
})
|
||||
kitInstance.Extensions().SetContext(ec)
|
||||
kitInstance.Extensions().EmitSessionStart()
|
||||
}
|
||||
|
||||
@@ -232,6 +173,20 @@ func (r *sessionRegistry) closeAll() {
|
||||
}
|
||||
}
|
||||
|
||||
// remove closes and removes a single session by ID.
|
||||
func (r *sessionRegistry) remove(sessionID string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
sess, ok := r.sessions[sessionID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if sess.kit != nil {
|
||||
_ = sess.kit.Close()
|
||||
}
|
||||
delete(r.sessions, sessionID)
|
||||
}
|
||||
|
||||
// cancelPrompt cancels the current prompt for a session, if any.
|
||||
func (s *acpSession) cancelPrompt() {
|
||||
s.cancelMu.Lock()
|
||||
@@ -255,40 +210,3 @@ func (s *acpSession) clearCancel() {
|
||||
defer s.cancelMu.Unlock()
|
||||
s.cancelFn = nil
|
||||
}
|
||||
|
||||
// sdkEventToSubagentEvent converts an SDK event to an extension SubagentEvent.
|
||||
func sdkEventToSubagentEvent(e kit.Event) extensions.SubagentEvent {
|
||||
switch ev := e.(type) {
|
||||
case kit.MessageUpdateEvent:
|
||||
return extensions.SubagentEvent{Type: "text", Content: ev.Chunk}
|
||||
case kit.ReasoningDeltaEvent:
|
||||
return extensions.SubagentEvent{Type: "reasoning", Content: ev.Delta}
|
||||
case kit.ToolCallEvent:
|
||||
return extensions.SubagentEvent{
|
||||
Type: "tool_call", ToolCallID: ev.ToolCallID,
|
||||
ToolName: ev.ToolName, ToolKind: ev.ToolKind, ToolArgs: ev.ToolArgs,
|
||||
}
|
||||
case kit.ToolExecutionStartEvent:
|
||||
return extensions.SubagentEvent{
|
||||
Type: "tool_execution_start", ToolCallID: ev.ToolCallID,
|
||||
ToolName: ev.ToolName, ToolKind: ev.ToolKind,
|
||||
}
|
||||
case kit.ToolExecutionEndEvent:
|
||||
return extensions.SubagentEvent{
|
||||
Type: "tool_execution_end", ToolCallID: ev.ToolCallID,
|
||||
ToolName: ev.ToolName, ToolKind: ev.ToolKind,
|
||||
}
|
||||
case kit.ToolResultEvent:
|
||||
return extensions.SubagentEvent{
|
||||
Type: "tool_result", ToolCallID: ev.ToolCallID,
|
||||
ToolName: ev.ToolName, ToolKind: ev.ToolKind,
|
||||
ToolResult: ev.Result, IsError: ev.IsError,
|
||||
}
|
||||
case kit.TurnStartEvent:
|
||||
return extensions.SubagentEvent{Type: "turn_start"}
|
||||
case kit.TurnEndEvent:
|
||||
return extensions.SubagentEvent{Type: "turn_end"}
|
||||
default:
|
||||
return extensions.SubagentEvent{}
|
||||
}
|
||||
}
|
||||
|
||||
+379
-72
@@ -6,6 +6,8 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
|
||||
@@ -58,6 +60,11 @@ type AgentConfig struct {
|
||||
// loading (successfully or with error). The callback receives the server
|
||||
// name, tool count, and any error. Called from the background goroutine.
|
||||
OnMCPServerLoaded func(serverName string, toolCount int, err error)
|
||||
|
||||
// MCPTaskConfig configures task-augmented tools/call execution. The
|
||||
// zero value preserves historical synchronous-only behaviour for any
|
||||
// server that didn't advertise task support during initialize.
|
||||
MCPTaskConfig tools.MCPTaskConfig
|
||||
}
|
||||
|
||||
// ToolCallHandler is a function type for handling tool calls as they happen.
|
||||
@@ -87,6 +94,19 @@ type ReasoningDeltaHandler func(delta string)
|
||||
// Called when the last reasoning token has been processed, before text streaming starts.
|
||||
type ReasoningCompleteHandler func()
|
||||
|
||||
// ToolCallStartHandler is a function type for handling the moment when the LLM
|
||||
// begins generating tool call arguments. The tool name is known but the full
|
||||
// argument JSON is still streaming.
|
||||
type ToolCallStartHandler func(toolCallID, toolName string)
|
||||
|
||||
// ToolCallDeltaHandler is a function type for handling streamed fragments of
|
||||
// tool call arguments as they arrive from the LLM.
|
||||
type ToolCallDeltaHandler func(toolCallID, delta string)
|
||||
|
||||
// ToolCallEndHandler is a function type for handling the end of tool argument
|
||||
// streaming, before the tool call is parsed and execution begins.
|
||||
type ToolCallEndHandler func(toolCallID string)
|
||||
|
||||
// ToolOutputHandler is a function type for handling streaming tool output chunks.
|
||||
// Used by tools like bash to stream output as it arrives rather than waiting
|
||||
// for the command to complete. The isStderr flag indicates if the chunk
|
||||
@@ -94,6 +114,12 @@ type ReasoningCompleteHandler func()
|
||||
// Note: This is an alias for core.ToolOutputCallback to avoid import cycles.
|
||||
type ToolOutputHandler = core.ToolOutputCallback
|
||||
|
||||
// PasswordPromptHandler is a function type for password prompts.
|
||||
// Used by the bash tool when sudo requires a password. The handler receives
|
||||
// a prompt message and returns the password and whether it was cancelled.
|
||||
// Note: This is an alias for core.PasswordPromptCallback.
|
||||
type PasswordPromptHandler = core.PasswordPromptCallback
|
||||
|
||||
// StepMessagesHandler is a function type for persisting messages after each
|
||||
// complete step in a multi-step agent turn. The handler receives the messages
|
||||
// produced by the step (typically an assistant message with tool calls followed
|
||||
@@ -107,6 +133,76 @@ type StepMessagesHandler func(stepMessages []fantasy.Message)
|
||||
// tracking during long-running tool-calling conversations.
|
||||
type StepUsageHandler func(inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens int64)
|
||||
|
||||
// StepStartHandler is called when a new LLM step begins within a turn.
|
||||
type StepStartHandler func(stepNumber int)
|
||||
|
||||
// StepFinishHandler is called when a step completes with full context.
|
||||
type StepFinishHandler func(stepNumber int, hasToolCalls bool, finishReason string, usage fantasy.Usage)
|
||||
|
||||
// TextStartHandler is called when the LLM begins generating text content.
|
||||
type TextStartHandler func(id string)
|
||||
|
||||
// TextEndHandler is called when the LLM finishes generating text content.
|
||||
type TextEndHandler func(id string)
|
||||
|
||||
// ReasoningStartHandler is called when the LLM begins reasoning/thinking.
|
||||
type ReasoningStartHandler func(id string)
|
||||
|
||||
// WarningsHandler is called when the LLM provider returns warnings.
|
||||
type WarningsHandler func(warnings []string)
|
||||
|
||||
// SourceHandler is called when the LLM references a source.
|
||||
type SourceHandler func(sourceType, id, url, title string)
|
||||
|
||||
// StreamFinishHandler is called when a per-step LLM stream completes.
|
||||
type StreamFinishHandler func(usage fantasy.Usage, finishReason string)
|
||||
|
||||
// ErrorHandler is called when an agent-level error occurs.
|
||||
type ErrorHandler func(err error)
|
||||
|
||||
// RetryHandler is called when the LLM request is retried.
|
||||
type RetryHandler func(attempt int, err error)
|
||||
|
||||
// PrepareStepHandler is called between steps to allow message modification.
|
||||
// It receives the step number and current messages, and returns replacement
|
||||
// messages (or nil to keep unchanged).
|
||||
type PrepareStepHandler func(stepNumber int, messages []fantasy.Message) []fantasy.Message
|
||||
|
||||
// GenerateCallbacks consolidates all callback functions for
|
||||
// GenerateWithCallbacks into a single struct, replacing what was previously
|
||||
// 16+ positional callback parameters. New fields default to nil, so adding
|
||||
// new callbacks does not break existing callers.
|
||||
type GenerateCallbacks struct {
|
||||
OnToolCall ToolCallHandler
|
||||
OnToolExecution ToolExecutionHandler
|
||||
OnToolResult ToolResultHandler
|
||||
OnResponse ResponseHandler
|
||||
OnToolCallContent ToolCallContentHandler
|
||||
OnStreamingResponse StreamingResponseHandler
|
||||
OnReasoningDelta ReasoningDeltaHandler
|
||||
OnReasoningComplete ReasoningCompleteHandler
|
||||
OnToolOutput ToolOutputHandler
|
||||
OnStepMessages StepMessagesHandler
|
||||
OnStepUsage StepUsageHandler
|
||||
OnPasswordPrompt PasswordPromptHandler
|
||||
OnToolCallStart ToolCallStartHandler
|
||||
OnToolCallDelta ToolCallDeltaHandler
|
||||
OnToolCallEnd ToolCallEndHandler
|
||||
|
||||
// New callbacks for previously unwired Fantasy lifecycle events.
|
||||
OnStepStart StepStartHandler
|
||||
OnStepFinish StepFinishHandler
|
||||
OnTextStart TextStartHandler
|
||||
OnTextEnd TextEndHandler
|
||||
OnReasoningStart ReasoningStartHandler
|
||||
OnWarnings WarningsHandler
|
||||
OnSource SourceHandler
|
||||
OnStreamFinish StreamFinishHandler
|
||||
OnError ErrorHandler
|
||||
OnRetry RetryHandler
|
||||
OnPrepareStep PrepareStepHandler
|
||||
}
|
||||
|
||||
// Agent represents an AI agent with core tool integration using the LLM library.
|
||||
// Core tools (bash, read, write, edit, grep, find, ls) are registered as direct
|
||||
// AgentTool implementations — no MCP layer, no serialization overhead.
|
||||
@@ -141,11 +237,21 @@ type Agent struct {
|
||||
authHandler tools.MCPAuthHandler
|
||||
tokenStoreFactory tools.TokenStoreFactory
|
||||
|
||||
// mcpTaskConfig is stored from AgentConfig so AddMCPServer() can
|
||||
// propagate it to a lazily-created MCPToolManager.
|
||||
mcpTaskConfig tools.MCPTaskConfig
|
||||
|
||||
// mcpReady is closed when background MCP tool loading completes (success
|
||||
// or failure). nil when no MCP servers are configured.
|
||||
mcpReady chan struct{}
|
||||
// mcpErr holds any error from background MCP loading.
|
||||
mcpErr error
|
||||
|
||||
// promptMu serializes runtime updates to systemPrompt and the
|
||||
// accompanying fantasy agent rebuild so concurrent SetSystemPrompt
|
||||
// callers (e.g. Kit.applyComposedSystemPrompt invoked from multiple
|
||||
// goroutines) don't race on a.systemPrompt / a.fantasyAgent.
|
||||
promptMu sync.Mutex
|
||||
}
|
||||
|
||||
// GenerateWithLoopResult contains the result and conversation history from an agent interaction.
|
||||
@@ -239,6 +345,7 @@ func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) {
|
||||
modelConfig: agentConfig.ModelConfig,
|
||||
authHandler: agentConfig.AuthHandler,
|
||||
tokenStoreFactory: agentConfig.TokenStoreFactory,
|
||||
mcpTaskConfig: agentConfig.MCPTaskConfig,
|
||||
}
|
||||
|
||||
// Start MCP tool loading in the background if servers are configured.
|
||||
@@ -258,6 +365,8 @@ func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) {
|
||||
if agentConfig.OnMCPServerLoaded != nil {
|
||||
toolManager.SetOnServerLoaded(agentConfig.OnMCPServerLoaded)
|
||||
}
|
||||
// Apply task-augmented tool execution config (zero value = no-op).
|
||||
toolManager.SetTaskConfig(agentConfig.MCPTaskConfig)
|
||||
a.toolManager = toolManager
|
||||
a.mcpReady = make(chan struct{})
|
||||
|
||||
@@ -404,22 +513,20 @@ func (a *Agent) GenerateWithLoop(ctx context.Context, messages []fantasy.Message
|
||||
onToolCall ToolCallHandler, onToolExecution ToolExecutionHandler, onToolResult ToolResultHandler,
|
||||
onResponse ResponseHandler, onToolCallContent ToolCallContentHandler,
|
||||
) (*GenerateWithLoopResult, error) {
|
||||
return a.GenerateWithLoopAndStreaming(ctx, messages, onToolCall, onToolExecution, onToolResult,
|
||||
onResponse, onToolCallContent, nil, nil, nil, nil, nil, nil)
|
||||
return a.GenerateWithCallbacks(ctx, messages, GenerateCallbacks{
|
||||
OnToolCall: onToolCall,
|
||||
OnToolExecution: onToolExecution,
|
||||
OnToolResult: onToolResult,
|
||||
OnResponse: onResponse,
|
||||
OnToolCallContent: onToolCallContent,
|
||||
})
|
||||
}
|
||||
|
||||
// GenerateWithLoopAndStreaming processes messages using the agent with streaming and callbacks.
|
||||
// GenerateWithCallbacks processes messages using the agent with streaming and callbacks.
|
||||
// The agent handles the tool call loop internally. We map the rich callback system
|
||||
// to kit's existing callback interface for UI integration.
|
||||
func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fantasy.Message,
|
||||
onToolCall ToolCallHandler, onToolExecution ToolExecutionHandler, onToolResult ToolResultHandler,
|
||||
onResponse ResponseHandler, onToolCallContent ToolCallContentHandler,
|
||||
onStreamingResponse StreamingResponseHandler,
|
||||
onReasoningDelta ReasoningDeltaHandler,
|
||||
onReasoningComplete ReasoningCompleteHandler,
|
||||
onToolOutput ToolOutputHandler,
|
||||
onStepMessages StepMessagesHandler,
|
||||
onStepUsage StepUsageHandler,
|
||||
func (a *Agent) GenerateWithCallbacks(ctx context.Context, messages []fantasy.Message,
|
||||
cb GenerateCallbacks,
|
||||
) (*GenerateWithLoopResult, error) {
|
||||
|
||||
// Wait for background MCP tool loading to complete and rebuild the
|
||||
@@ -428,8 +535,13 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
a.ensureMCPTools()
|
||||
|
||||
// Inject tool output handler into context for use by core tools (e.g., bash).
|
||||
if onToolOutput != nil {
|
||||
ctx = core.ContextWithToolOutputCallback(ctx, onToolOutput)
|
||||
if cb.OnToolOutput != nil {
|
||||
ctx = core.ContextWithToolOutputCallback(ctx, cb.OnToolOutput)
|
||||
}
|
||||
|
||||
// Inject password prompt handler into context for use by bash tool.
|
||||
if cb.OnPasswordPrompt != nil {
|
||||
ctx = core.ContextWithPasswordPrompt(ctx, cb.OnPasswordPrompt)
|
||||
}
|
||||
|
||||
// The agent requires the current user input as Prompt, with prior messages as history.
|
||||
@@ -442,15 +554,25 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
// This avoids type conflicts with provider-level options.
|
||||
history = applyCacheControlToMessages(history)
|
||||
|
||||
// Track current tool call args for callbacks
|
||||
var currentToolArgs string
|
||||
// Track tool call args per-ToolCallID so parallel tool calls in a single
|
||||
// step don't clobber each other. Without this, OnToolResult callbacks would
|
||||
// all see the args of the last OnToolCall in the step. The mutex guards
|
||||
// against the possibility that the underlying streaming layer dispatches
|
||||
// callbacks from multiple goroutines.
|
||||
toolCallArgs := make(map[string]string)
|
||||
var toolCallArgsMu sync.Mutex
|
||||
|
||||
// Use the streaming path when streaming is enabled OR when any callbacks are
|
||||
// provided. The agent only exposes tool/step callbacks on AgentStreamCall, so
|
||||
// Stream is required to observe tool execution in real time. The non-streaming
|
||||
// Generate path is reserved for the simple case with no callbacks at all.
|
||||
hasCallbacks := onToolCall != nil || onToolExecution != nil || onToolResult != nil ||
|
||||
onToolCallContent != nil || onStreamingResponse != nil || onReasoningDelta != nil
|
||||
hasCallbacks := cb.OnToolCall != nil || cb.OnToolExecution != nil || cb.OnToolResult != nil ||
|
||||
cb.OnToolCallContent != nil || cb.OnStreamingResponse != nil || cb.OnReasoningDelta != nil ||
|
||||
cb.OnToolCallStart != nil || cb.OnToolCallDelta != nil || cb.OnToolCallEnd != nil ||
|
||||
cb.OnStepStart != nil || cb.OnStepFinish != nil || cb.OnTextStart != nil ||
|
||||
cb.OnTextEnd != nil || cb.OnReasoningStart != nil || cb.OnWarnings != nil ||
|
||||
cb.OnSource != nil || cb.OnStreamFinish != nil || cb.OnError != nil ||
|
||||
cb.OnRetry != nil || cb.OnPrepareStep != nil
|
||||
|
||||
if a.streamingEnabled || hasCallbacks {
|
||||
// Track completed step messages so we can return partial results
|
||||
@@ -459,9 +581,11 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
// for every step that completed before the error occurred.
|
||||
var completedStepMessages []fantasy.Message
|
||||
// persistedCount tracks how many new messages (beyond the original
|
||||
// input) were persisted incrementally via onStepMessages, so the
|
||||
// input) were persisted incrementally via cb.OnStepMessages, so the
|
||||
// caller can skip them during post-generation persistence.
|
||||
var persistedCount int
|
||||
// stepCounter tracks the current step number for StepStart/StepFinish events.
|
||||
var stepCounter int
|
||||
|
||||
// Use the streaming agent
|
||||
streamCall := fantasy.AgentStreamCall{
|
||||
@@ -469,13 +593,73 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
Files: files,
|
||||
Messages: history,
|
||||
|
||||
// Tool input streaming callbacks — fire during tool argument generation
|
||||
OnToolInputStart: func(id, toolName string) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if cb.OnToolCallStart != nil {
|
||||
cb.OnToolCallStart(id, toolName)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
OnToolInputDelta: func(id, delta string) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if cb.OnToolCallDelta != nil {
|
||||
cb.OnToolCallDelta(id, delta)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
OnToolInputEnd: func(id string) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if cb.OnToolCallEnd != nil {
|
||||
cb.OnToolCallEnd(id)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
// Text start/end callbacks
|
||||
OnTextStart: func(id string) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if cb.OnTextStart != nil {
|
||||
cb.OnTextStart(id)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
OnTextEnd: func(id string) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if cb.OnTextEnd != nil {
|
||||
cb.OnTextEnd(id)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
// Reasoning start callback
|
||||
OnReasoningStart: func(id string, _ fantasy.ReasoningContent) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if cb.OnReasoningStart != nil {
|
||||
cb.OnReasoningStart(id)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
// Reasoning/thinking streaming callback
|
||||
OnReasoningDelta: func(id, delta string) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if onReasoningDelta != nil {
|
||||
onReasoningDelta(delta)
|
||||
if cb.OnReasoningDelta != nil {
|
||||
cb.OnReasoningDelta(delta)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -485,8 +669,8 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if onReasoningComplete != nil {
|
||||
onReasoningComplete()
|
||||
if cb.OnReasoningComplete != nil {
|
||||
cb.OnReasoningComplete()
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -496,8 +680,64 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if onStreamingResponse != nil {
|
||||
onStreamingResponse(text)
|
||||
if cb.OnStreamingResponse != nil {
|
||||
cb.OnStreamingResponse(text)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
// Warnings callback
|
||||
OnWarnings: func(warnings []fantasy.CallWarning) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if cb.OnWarnings != nil {
|
||||
strs := make([]string, len(warnings))
|
||||
for i, w := range warnings {
|
||||
strs[i] = w.Message
|
||||
}
|
||||
cb.OnWarnings(strs)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
// Source callback
|
||||
OnSource: func(source fantasy.SourceContent) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if cb.OnSource != nil {
|
||||
cb.OnSource(string(source.SourceType), source.ID, source.URL, source.Title)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
// Stream finish callback (per-step stream completion)
|
||||
OnStreamFinish: func(usage fantasy.Usage, finishReason fantasy.FinishReason, _ fantasy.ProviderMetadata) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if cb.OnStreamFinish != nil {
|
||||
cb.OnStreamFinish(usage, string(finishReason))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
// Error callback
|
||||
OnError: func(err error) {
|
||||
if cb.OnError != nil {
|
||||
cb.OnError(err)
|
||||
}
|
||||
},
|
||||
|
||||
// Step start callback
|
||||
OnStepStart: func(stepNumber int) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
stepCounter = stepNumber
|
||||
if cb.OnStepStart != nil {
|
||||
cb.OnStepStart(stepNumber)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -507,16 +747,18 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
currentToolArgs = tc.Input
|
||||
toolCallArgsMu.Lock()
|
||||
toolCallArgs[tc.ToolCallID] = tc.Input
|
||||
toolCallArgsMu.Unlock()
|
||||
|
||||
// Notify about the tool call
|
||||
if onToolCall != nil {
|
||||
onToolCall(tc.ToolCallID, tc.ToolName, tc.Input)
|
||||
if cb.OnToolCall != nil {
|
||||
cb.OnToolCall(tc.ToolCallID, tc.ToolName, tc.Input)
|
||||
}
|
||||
|
||||
// Notify tool execution starting
|
||||
if onToolExecution != nil {
|
||||
onToolExecution(tc.ToolCallID, tc.ToolName, tc.Input, true)
|
||||
if cb.OnToolExecution != nil {
|
||||
cb.OnToolExecution(tc.ToolCallID, tc.ToolName, tc.Input, true)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -527,15 +769,22 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
// Look up the args recorded for this specific tool call. Delete
|
||||
// the entry so the map doesn't accumulate across steps.
|
||||
toolCallArgsMu.Lock()
|
||||
args := toolCallArgs[tr.ToolCallID]
|
||||
delete(toolCallArgs, tr.ToolCallID)
|
||||
toolCallArgsMu.Unlock()
|
||||
|
||||
// Notify tool execution finished
|
||||
if onToolExecution != nil {
|
||||
onToolExecution(tr.ToolCallID, tr.ToolName, currentToolArgs, false)
|
||||
if cb.OnToolExecution != nil {
|
||||
cb.OnToolExecution(tr.ToolCallID, tr.ToolName, args, false)
|
||||
}
|
||||
|
||||
if onToolResult != nil {
|
||||
if cb.OnToolResult != nil {
|
||||
// Extract result text and error status
|
||||
resultText, isError := extractToolResultText(tr)
|
||||
onToolResult(tr.ToolCallID, tr.ToolName, currentToolArgs, resultText, tr.ClientMetadata, isError)
|
||||
cb.OnToolResult(tr.ToolCallID, tr.ToolName, args, resultText, tr.ClientMetadata, isError)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -549,8 +798,8 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
|
||||
// Persist step messages incrementally so progress is saved
|
||||
// as it happens rather than only at the end of the turn.
|
||||
if onStepMessages != nil && len(step.Messages) > 0 {
|
||||
onStepMessages(step.Messages)
|
||||
if cb.OnStepMessages != nil && len(step.Messages) > 0 {
|
||||
cb.OnStepMessages(step.Messages)
|
||||
persistedCount += len(step.Messages)
|
||||
}
|
||||
|
||||
@@ -560,65 +809,88 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
// Check if step has text content alongside tool calls
|
||||
text := step.Content.Text()
|
||||
toolCalls := step.Content.ToolCalls()
|
||||
if text != "" && len(toolCalls) > 0 && onToolCallContent != nil {
|
||||
onToolCallContent(text)
|
||||
if text != "" && len(toolCalls) > 0 && cb.OnToolCallContent != nil {
|
||||
cb.OnToolCallContent(text)
|
||||
}
|
||||
// Emit step usage for real-time cost tracking
|
||||
if onStepUsage != nil {
|
||||
onStepUsage(step.Usage.InputTokens, step.Usage.OutputTokens,
|
||||
if cb.OnStepUsage != nil {
|
||||
cb.OnStepUsage(step.Usage.InputTokens, step.Usage.OutputTokens,
|
||||
step.Usage.CacheReadTokens, step.Usage.CacheCreationTokens)
|
||||
}
|
||||
// Emit unified step finish event
|
||||
if cb.OnStepFinish != nil {
|
||||
cb.OnStepFinish(stepCounter, len(toolCalls) > 0, string(step.FinishReason), step.Usage)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// If a steer channel is attached to the context, wire up a
|
||||
// PrepareStep function that drains the channel between steps
|
||||
// and injects pending steer messages as user messages before
|
||||
// the next LLM call. This enables graceful mid-turn steering
|
||||
// without cancelling in-progress tool execution.
|
||||
if steerCh := steerChFromContext(ctx); steerCh != nil {
|
||||
onConsumed := steerConsumedFromContext(ctx)
|
||||
// Always wire up PrepareStep to handle both steering and the
|
||||
// OnPrepareStep hook. Steering drains its channel first, then
|
||||
// OnPrepareStep hooks run against the (possibly already steered)
|
||||
// messages.
|
||||
steerCh := steerChFromContext(ctx)
|
||||
onConsumed := steerConsumedFromContext(ctx)
|
||||
hasSteering := steerCh != nil
|
||||
hasPrepareStepHook := cb.OnPrepareStep != nil
|
||||
|
||||
if hasSteering || hasPrepareStepHook {
|
||||
streamCall.PrepareStep = func(
|
||||
stepCtx context.Context,
|
||||
opts fantasy.PrepareStepFunctionOptions,
|
||||
) (context.Context, fantasy.PrepareStepResult, error) {
|
||||
// Drain all pending steer messages (non-blocking).
|
||||
var steered []SteerMessage
|
||||
for {
|
||||
select {
|
||||
case msg := <-steerCh:
|
||||
steered = append(steered, msg)
|
||||
default:
|
||||
goto done
|
||||
}
|
||||
}
|
||||
done:
|
||||
result := fantasy.PrepareStepResult{
|
||||
Model: opts.Model,
|
||||
Messages: opts.Messages,
|
||||
}
|
||||
if len(steered) > 0 {
|
||||
// Inject each steer message as a user message so the
|
||||
// LLM sees the redirection on the next step.
|
||||
for _, sm := range steered {
|
||||
result.Messages = append(result.Messages,
|
||||
fantasy.NewUserMessage(sm.Text, sm.Files...))
|
||||
|
||||
// Phase 1: Drain steering channel (if present).
|
||||
if hasSteering {
|
||||
var steered []SteerMessage
|
||||
for {
|
||||
select {
|
||||
case msg := <-steerCh:
|
||||
steered = append(steered, msg)
|
||||
default:
|
||||
goto done
|
||||
}
|
||||
}
|
||||
// Notify that steer messages were consumed.
|
||||
if onConsumed != nil {
|
||||
onConsumed(len(steered))
|
||||
done:
|
||||
if len(steered) > 0 {
|
||||
for _, sm := range steered {
|
||||
result.Messages = append(result.Messages,
|
||||
fantasy.NewUserMessage(sm.Text, sm.Files...))
|
||||
}
|
||||
if onConsumed != nil {
|
||||
onConsumed(len(steered))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Run OnPrepareStep hook (if registered).
|
||||
if hasPrepareStepHook {
|
||||
if replacement := cb.OnPrepareStep(opts.StepNumber, result.Messages); replacement != nil {
|
||||
result.Messages = replacement
|
||||
}
|
||||
}
|
||||
|
||||
// Apply message-level cache control for Anthropic models.
|
||||
// This avoids type conflicts with provider-level options.
|
||||
result.Messages = applyCacheControlToMessages(result.Messages)
|
||||
|
||||
return stepCtx, result, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Wire OnRetry callback if provided.
|
||||
if cb.OnRetry != nil {
|
||||
streamCall.OnRetry = func(err *fantasy.ProviderError, _ time.Duration) {
|
||||
// Use the retry number from the error if available; Fantasy
|
||||
// doesn't pass a counter directly, so we approximate with a
|
||||
// counter incremented on each call.
|
||||
cb.OnRetry(0, err)
|
||||
}
|
||||
}
|
||||
|
||||
result, err := a.fantasyAgent.Stream(ctx, streamCall)
|
||||
if err != nil {
|
||||
// On cancellation (or any error), return a partial result
|
||||
@@ -644,8 +916,8 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
// empty (e.g. reasoning-only responses) so the UI properly resets
|
||||
// the stream component and avoids duplicate content on the next
|
||||
// flush.
|
||||
if onResponse != nil {
|
||||
onResponse(result.Response.Content.Text())
|
||||
if cb.OnResponse != nil {
|
||||
cb.OnResponse(result.Response.Content.Text())
|
||||
}
|
||||
|
||||
r := convertAgentResult(result, messages)
|
||||
@@ -665,8 +937,8 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
|
||||
// For non-streaming, fire the response callback so callers can reset
|
||||
// streaming state (see streaming path comment above).
|
||||
if onResponse != nil {
|
||||
onResponse(result.Response.Content.Text())
|
||||
if cb.OnResponse != nil {
|
||||
cb.OnResponse(result.Response.Content.Text())
|
||||
}
|
||||
|
||||
return convertAgentResult(result, messages), nil
|
||||
@@ -857,6 +1129,7 @@ func (a *Agent) AddMCPServer(ctx context.Context, name string, cfg config.MCPSer
|
||||
if a.tokenStoreFactory != nil {
|
||||
a.toolManager.SetTokenStoreFactory(a.tokenStoreFactory)
|
||||
}
|
||||
a.toolManager.SetTaskConfig(a.mcpTaskConfig)
|
||||
a.toolManager.SetOnToolsChanged(func() {
|
||||
a.rebuildFantasyAgent()
|
||||
})
|
||||
@@ -1013,6 +1286,40 @@ func (a *Agent) GetModel() fantasy.LanguageModel {
|
||||
return a.model
|
||||
}
|
||||
|
||||
// SetSystemPrompt updates the agent's system prompt and rebuilds the underlying
|
||||
// fantasy agent so subsequent turns use the new prompt. Safe to call while the
|
||||
// agent is idle; if invoked during an in-flight turn the new prompt takes
|
||||
// effect on the next LLM call.
|
||||
func (a *Agent) SetSystemPrompt(prompt string) {
|
||||
a.promptMu.Lock()
|
||||
defer a.promptMu.Unlock()
|
||||
a.systemPrompt = prompt
|
||||
a.rebuildFantasyAgent()
|
||||
}
|
||||
|
||||
// GetSystemPrompt returns the agent's current system prompt.
|
||||
func (a *Agent) GetSystemPrompt() string {
|
||||
a.promptMu.Lock()
|
||||
defer a.promptMu.Unlock()
|
||||
return a.systemPrompt
|
||||
}
|
||||
|
||||
// GetMaxTokens returns the effective max output tokens the agent currently
|
||||
// sends to the LLM provider, after per-model defaults, right-sizing, and any
|
||||
// Anthropic thinking-budget adjustments. Returns 0 when no ModelConfig is
|
||||
// attached (e.g. early init) or when the provider suppresses the parameter
|
||||
// (e.g. Codex OAuth), which allows callers to differentiate "default" from
|
||||
// "explicitly capped".
|
||||
func (a *Agent) GetMaxTokens() int {
|
||||
if a.skipMaxOutputTokens {
|
||||
return 0
|
||||
}
|
||||
if a.modelConfig == nil {
|
||||
return 0
|
||||
}
|
||||
return a.modelConfig.MaxTokens
|
||||
}
|
||||
|
||||
// Close closes the agent and cleans up resources.
|
||||
// If MCP tools are still loading in the background, Close waits for them
|
||||
// to finish before closing connections to avoid resource leaks.
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
)
|
||||
|
||||
// fakeParallelAgent simulates a provider that emits two parallel tool_use
|
||||
// blocks in a single step. It invokes the streaming callbacks in the order:
|
||||
//
|
||||
// OnToolCall(A) -> OnToolCall(B) -> OnToolResult(A) -> OnToolResult(B)
|
||||
//
|
||||
// Before the fix in #33 the agent-layer wrapper recorded a single
|
||||
// `currentToolArgs` variable that was clobbered by the second OnToolCall, so
|
||||
// both OnToolResult callbacks received B's args instead of their own.
|
||||
type fakeParallelAgent struct {
|
||||
calls []fantasy.ToolCallContent
|
||||
results []fantasy.ToolResultContent
|
||||
}
|
||||
|
||||
func (f *fakeParallelAgent) Generate(_ context.Context, _ fantasy.AgentCall) (*fantasy.AgentResult, error) {
|
||||
return &fantasy.AgentResult{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeParallelAgent) Stream(_ context.Context, opts fantasy.AgentStreamCall) (*fantasy.AgentResult, error) {
|
||||
for _, tc := range f.calls {
|
||||
if opts.OnToolCall != nil {
|
||||
if err := opts.OnToolCall(tc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, tr := range f.results {
|
||||
if opts.OnToolResult != nil {
|
||||
if err := opts.OnToolResult(tr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return &fantasy.AgentResult{}, nil
|
||||
}
|
||||
|
||||
// TestGenerateWithCallbacks_ParallelToolArgs is the regression test for #33.
|
||||
// It drives the streaming-callback wiring inside GenerateWithCallbacks with a
|
||||
// fake fantasy.Agent that emits two parallel tool calls before either result.
|
||||
// Each OnToolResult must receive the args of its own tool call (matched by
|
||||
// ToolCallID), not the args of the last OnToolCall in the step.
|
||||
func TestGenerateWithCallbacks_ParallelToolArgs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
argsA := `{"name":"scheduled_jobs"}`
|
||||
argsB := `{"name":"gmail_trigger"}`
|
||||
|
||||
fake := &fakeParallelAgent{
|
||||
calls: []fantasy.ToolCallContent{
|
||||
{ToolCallID: "kit-A", ToolName: "load_skill", Input: argsA},
|
||||
{ToolCallID: "kit-B", ToolName: "load_skill", Input: argsB},
|
||||
},
|
||||
results: []fantasy.ToolResultContent{
|
||||
{ToolCallID: "kit-A", ToolName: "load_skill", Result: fantasy.ToolResultOutputContentText{Text: "ok-A"}},
|
||||
{ToolCallID: "kit-B", ToolName: "load_skill", Result: fantasy.ToolResultOutputContentText{Text: "ok-B"}},
|
||||
},
|
||||
}
|
||||
|
||||
a := &Agent{
|
||||
fantasyAgent: fake,
|
||||
streamingEnabled: false, // exercise the "hasCallbacks" branch
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
resultArgs := map[string]string{}
|
||||
executionArgs := map[string]string{} // captured when running == false
|
||||
|
||||
cb := GenerateCallbacks{
|
||||
OnToolExecution: func(id, _, args string, running bool) {
|
||||
if running {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
executionArgs[id] = args
|
||||
},
|
||||
OnToolResult: func(id, _, args, _, _ string, _ bool) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
resultArgs[id] = args
|
||||
},
|
||||
}
|
||||
|
||||
if _, err := a.GenerateWithCallbacks(context.Background(), nil, cb); err != nil {
|
||||
t.Fatalf("GenerateWithCallbacks returned error: %v", err)
|
||||
}
|
||||
|
||||
if got, want := resultArgs["kit-A"], argsA; got != want {
|
||||
t.Errorf("OnToolResult for kit-A: args = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := resultArgs["kit-B"], argsB; got != want {
|
||||
t.Errorf("OnToolResult for kit-B: args = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := executionArgs["kit-A"], argsA; got != want {
|
||||
t.Errorf("OnToolExecution(finish) for kit-A: args = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := executionArgs["kit-B"], argsB; got != want {
|
||||
t.Errorf("OnToolExecution(finish) for kit-B: args = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,8 @@ type AgentCreationOptions struct {
|
||||
// OnMCPServerLoaded, if non-nil, is called when each MCP server finishes
|
||||
// loading (successfully or with error). Called from the background goroutine.
|
||||
OnMCPServerLoaded func(serverName string, toolCount int, err error)
|
||||
// MCPTaskConfig configures task-augmented tools/call execution.
|
||||
MCPTaskConfig tools.MCPTaskConfig
|
||||
}
|
||||
|
||||
// CreateAgent creates an agent with optional spinner for Ollama models.
|
||||
@@ -76,6 +78,7 @@ func CreateAgent(ctx context.Context, opts *AgentCreationOptions) (*Agent, error
|
||||
ToolWrapper: opts.ToolWrapper,
|
||||
ExtraTools: opts.ExtraTools,
|
||||
OnMCPServerLoaded: opts.OnMCPServerLoaded,
|
||||
MCPTaskConfig: opts.MCPTaskConfig,
|
||||
}
|
||||
|
||||
var agent *Agent
|
||||
|
||||
@@ -9,12 +9,19 @@ import (
|
||||
"github.com/mark3labs/kit/internal/tools"
|
||||
)
|
||||
|
||||
// mcpExecutor is the subset of *tools.MCPToolManager that the adapter
|
||||
// actually uses. Extracted as an interface so the adapter is unit-testable
|
||||
// without constructing a full manager + connection pool.
|
||||
type mcpExecutor interface {
|
||||
ExecuteTool(ctx context.Context, prefixedName, inputJSON string) (*tools.MCPToolResult, error)
|
||||
}
|
||||
|
||||
// mcpAgentTool adapts an tools.MCPTool to the fantasy.AgentTool interface.
|
||||
// This keeps the fantasy dependency confined to the agent layer — the tools
|
||||
// package is a pure MCP client library with no LLM framework dependency.
|
||||
type mcpAgentTool struct {
|
||||
tool tools.MCPTool
|
||||
manager *tools.MCPToolManager
|
||||
exec mcpExecutor
|
||||
providerOptions fantasy.ProviderOptions
|
||||
}
|
||||
|
||||
@@ -29,10 +36,26 @@ func (t *mcpAgentTool) Info() fantasy.ToolInfo {
|
||||
}
|
||||
|
||||
// Run executes the MCP tool by delegating to the MCPToolManager.
|
||||
//
|
||||
// MCP-side failures (JSON-RPC protocol errors, transport failures, schema
|
||||
// validation rejections from the server) are surfaced to the model as soft
|
||||
// tool errors rather than escalated to a critical agent error. This matches
|
||||
// the contract that native Kit tools follow via kit.ErrorResult(...) and
|
||||
// lets the model self-correct (e.g. retry with a fixed argument shape) or
|
||||
// give up gracefully rather than aborting the turn mid-run.
|
||||
//
|
||||
// Context cancellation is the one exception: if the caller cancelled the
|
||||
// context the turn was aborted intentionally, so we propagate the ctx error
|
||||
// to let the agent loop unwind cleanly.
|
||||
func (t *mcpAgentTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
result, err := t.manager.ExecuteTool(ctx, t.tool.Name, call.Input)
|
||||
result, err := t.exec.ExecuteTool(ctx, t.tool.Name, call.Input)
|
||||
if err != nil {
|
||||
return fantasy.ToolResponse{}, fmt.Errorf("mcp tool execution failed: %w", err)
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return fantasy.ToolResponse{}, ctxErr
|
||||
}
|
||||
return fantasy.NewTextErrorResponse(
|
||||
fmt.Sprintf("MCP tool %q failed: %s", t.tool.Name, err.Error()),
|
||||
), nil
|
||||
}
|
||||
|
||||
if result.IsError {
|
||||
@@ -57,8 +80,8 @@ func mcpToolsToAgentTools(mcpTools []tools.MCPTool, manager *tools.MCPToolManage
|
||||
agentTools := make([]fantasy.AgentTool, len(mcpTools))
|
||||
for i, t := range mcpTools {
|
||||
agentTools[i] = &mcpAgentTool{
|
||||
tool: t,
|
||||
manager: manager,
|
||||
tool: t,
|
||||
exec: manager,
|
||||
}
|
||||
}
|
||||
return agentTools
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
|
||||
"github.com/mark3labs/kit/internal/tools"
|
||||
)
|
||||
|
||||
// stubExecutor lets each test script the (result, err) pair returned by
|
||||
// ExecuteTool. The adapter holds an mcpExecutor interface, so this is the
|
||||
// only seam the tests need.
|
||||
type stubExecutor struct {
|
||||
result *tools.MCPToolResult
|
||||
err error
|
||||
// called records the last invocation for assertion.
|
||||
called bool
|
||||
name string
|
||||
input string
|
||||
}
|
||||
|
||||
func (s *stubExecutor) ExecuteTool(_ context.Context, prefixedName, inputJSON string) (*tools.MCPToolResult, error) {
|
||||
s.called = true
|
||||
s.name = prefixedName
|
||||
s.input = inputJSON
|
||||
return s.result, s.err
|
||||
}
|
||||
|
||||
func newMCPAgentTool(exec mcpExecutor, name string) *mcpAgentTool {
|
||||
return &mcpAgentTool{
|
||||
tool: tools.MCPTool{Name: name},
|
||||
exec: exec,
|
||||
}
|
||||
}
|
||||
|
||||
// Manager-side Go errors (JSON-RPC protocol errors, transport failures,
|
||||
// schema validation rejections from the MCP server) must be surfaced to
|
||||
// the model as soft tool errors so the agent loop can keep going. Aborting
|
||||
// the turn would discard all prior tool results — see issue #N.
|
||||
func TestMCPAgentTool_RPCErrorBecomesSoftError(t *testing.T) {
|
||||
exec := &stubExecutor{
|
||||
err: errors.New("MCP error -32602: Invalid params: missing field \"task\""),
|
||||
}
|
||||
tool := newMCPAgentTool(exec, "pubmed__search")
|
||||
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "pubmed__search",
|
||||
Input: `{"query":"foo"}`,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error (soft), got %v", err)
|
||||
}
|
||||
if !resp.IsError {
|
||||
t.Fatalf("expected IsError=true, got false")
|
||||
}
|
||||
if !strings.Contains(resp.Content, "pubmed__search") {
|
||||
t.Errorf("expected tool name in error content, got %q", resp.Content)
|
||||
}
|
||||
if !strings.Contains(resp.Content, "-32602") {
|
||||
t.Errorf("expected underlying error text in content, got %q", resp.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// Context cancellation is the one error that must remain critical: it
|
||||
// means the caller intentionally aborted, and the agent loop needs to
|
||||
// unwind cleanly rather than burning more steps.
|
||||
func TestMCPAgentTool_CtxCancelStaysCritical(t *testing.T) {
|
||||
exec := &stubExecutor{
|
||||
// Real managers typically return ctx.Err() (or a wrapper) when the
|
||||
// context is cancelled mid-call.
|
||||
err: context.Canceled,
|
||||
}
|
||||
tool := newMCPAgentTool(exec, "slow__tool")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
resp, err := tool.Run(ctx, fantasy.ToolCall{Name: "slow__tool"})
|
||||
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("expected context.Canceled, got %v", err)
|
||||
}
|
||||
if resp.IsError || resp.Content != "" {
|
||||
t.Errorf("expected empty response on critical error, got IsError=%v Content=%q", resp.IsError, resp.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// Deadline-exceeded behaves the same as cancellation: ctx.Err() is
|
||||
// non-nil, so the adapter must propagate the critical error rather than
|
||||
// converting the executor's error into a soft response.
|
||||
func TestMCPAgentTool_CtxDeadlineStaysCritical(t *testing.T) {
|
||||
exec := &stubExecutor{err: context.DeadlineExceeded}
|
||||
tool := newMCPAgentTool(exec, "slow__tool")
|
||||
|
||||
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
|
||||
defer cancel()
|
||||
|
||||
resp, err := tool.Run(ctx, fantasy.ToolCall{Name: "slow__tool"})
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("expected context.DeadlineExceeded, got %v", err)
|
||||
}
|
||||
if resp.IsError || resp.Content != "" {
|
||||
t.Errorf("expected empty response on critical error, got IsError=%v Content=%q", resp.IsError, resp.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// Server-side soft errors (CallToolResult{ isError: true }) must continue
|
||||
// to flow through as soft errors — this was the existing behavior and
|
||||
// must not regress.
|
||||
func TestMCPAgentTool_ServerIsErrorRemainsSoftError(t *testing.T) {
|
||||
exec := &stubExecutor{
|
||||
result: &tools.MCPToolResult{
|
||||
IsError: true,
|
||||
Content: "search service is rate limited; try again in 30s",
|
||||
},
|
||||
}
|
||||
tool := newMCPAgentTool(exec, "pubmed__search")
|
||||
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{Name: "pubmed__search"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
if !resp.IsError {
|
||||
t.Fatalf("expected IsError=true, got false")
|
||||
}
|
||||
if resp.Content != "search service is rate limited; try again in 30s" {
|
||||
t.Errorf("expected pass-through content, got %q", resp.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// Happy path: ordinary successful tool result is passed through unchanged.
|
||||
func TestMCPAgentTool_SuccessIsPassthrough(t *testing.T) {
|
||||
exec := &stubExecutor{
|
||||
result: &tools.MCPToolResult{
|
||||
IsError: false,
|
||||
Content: `{"hits":3}`,
|
||||
},
|
||||
}
|
||||
tool := newMCPAgentTool(exec, "pubmed__search")
|
||||
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{Name: "pubmed__search"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.IsError {
|
||||
t.Fatalf("expected IsError=false")
|
||||
}
|
||||
if resp.Content != `{"hits":3}` {
|
||||
t.Errorf("expected pass-through content, got %q", resp.Content)
|
||||
}
|
||||
}
|
||||
+323
-40
@@ -13,6 +13,7 @@ import (
|
||||
"charm.land/fantasy"
|
||||
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
"github.com/mark3labs/kit/internal/message"
|
||||
"github.com/mark3labs/kit/internal/session"
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
)
|
||||
@@ -70,14 +71,24 @@ type App struct {
|
||||
rootCtx context.Context
|
||||
rootCancel context.CancelFunc
|
||||
|
||||
// widgetUpdatePending is set to true when a WidgetUpdateEvent has been
|
||||
// sent to the TUI but not yet consumed by its event loop. While the flag
|
||||
// is set, subsequent NotifyWidgetUpdate calls are coalesced (dropped) to
|
||||
// prevent fast extension tickers from flooding the BubbleTea mailbox with
|
||||
// redundant re-render triggers. The flag is cleared after a short debounce
|
||||
// (~1 frame) so new updates are always let through once the TUI has had a
|
||||
// chance to process the pending event.
|
||||
widgetUpdatePending atomic.Bool
|
||||
// widgetUpdatePending is set to true while a WidgetUpdateEvent burst is
|
||||
// being coalesced. The leading edge fires immediately; subsequent calls
|
||||
// within the debounce window set widgetUpdateTrailing so a final event
|
||||
// is delivered with the latest runner state at the end of the window.
|
||||
// Without the trailing send, a rapid SetWidget→RemoveWidget pair (e.g.
|
||||
// SubagentEnd pushing a final frame then removing the widget) would let
|
||||
// the second call get silently dropped, leaving the TUI's layout stuck
|
||||
// on the pre-removal widget height — visible as empty rows below the
|
||||
// status bar after the widget disappears.
|
||||
widgetUpdatePending atomic.Bool
|
||||
widgetUpdateTrailing atomic.Bool
|
||||
|
||||
// steerDrainFn is the test seam used by releaseBusyAfterCompact to pull
|
||||
// any steer messages that arrived during compaction. In production it is
|
||||
// nil and the helper falls back to a.opts.Kit.DrainSteer(); tests that
|
||||
// need to exercise the steer-drain path without standing up a full
|
||||
// *kit.Kit can set this field directly to inject fake items.
|
||||
steerDrainFn func() []queueItem
|
||||
}
|
||||
|
||||
// New creates a new App with the provided options and pre-loaded messages.
|
||||
@@ -333,6 +344,90 @@ func (a *App) SwitchTreeSession(ts *session.TreeManager) {
|
||||
}
|
||||
}
|
||||
|
||||
// PopLastUserMessage truncates the tree session back to the parent of the
|
||||
// most recent user message on the current branch, syncs the in-memory
|
||||
// message store, and returns the user prompt text plus any image file
|
||||
// parts so the caller can resubmit via Run/RunWithFiles.
|
||||
//
|
||||
// This is the building block for /retry: the user message and any orphaned
|
||||
// assistant/tool entries produced by a failed turn become unreachable on
|
||||
// the current branch (they remain in the session file under a different
|
||||
// leaf) and are excluded from the next LLM context.
|
||||
//
|
||||
// Returns an error when:
|
||||
// - the agent is currently working (busy)
|
||||
// - the app has been closed
|
||||
// - no tree session is active (sessions disabled via --no-session)
|
||||
// - no user message exists on the current branch
|
||||
//
|
||||
// Satisfies ui.AppController.
|
||||
func (a *App) PopLastUserMessage() (string, []kit.LLMFilePart, error) {
|
||||
a.mu.Lock()
|
||||
if a.closed {
|
||||
a.mu.Unlock()
|
||||
return "", nil, fmt.Errorf("app is closed")
|
||||
}
|
||||
if a.busy {
|
||||
a.mu.Unlock()
|
||||
return "", nil, fmt.Errorf("cannot retry while the agent is working")
|
||||
}
|
||||
a.mu.Unlock()
|
||||
|
||||
ts := a.opts.TreeSession
|
||||
if ts == nil {
|
||||
return "", nil, fmt.Errorf("no tree session active; /retry requires a session")
|
||||
}
|
||||
|
||||
// Walk the current branch backwards to find the most recent user message.
|
||||
branch := ts.GetBranch("")
|
||||
var target *session.MessageEntry
|
||||
for i := len(branch) - 1; i >= 0; i-- {
|
||||
me, ok := branch[i].(*session.MessageEntry)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if me.Role == string(message.RoleUser) {
|
||||
target = me
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
return "", nil, fmt.Errorf("no user message to retry")
|
||||
}
|
||||
|
||||
// Extract the prompt text and any image parts from the target entry.
|
||||
msg, err := target.ToMessage()
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("decode user message: %w", err)
|
||||
}
|
||||
prompt := msg.Content()
|
||||
var files []kit.LLMFilePart
|
||||
for _, part := range msg.Parts {
|
||||
if ic, ok := part.(message.ImageContent); ok {
|
||||
files = append(files, kit.LLMFilePart{
|
||||
Data: ic.Data,
|
||||
MediaType: ic.MediaType,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Move the leaf to the parent of the user message. The failed turn's
|
||||
// entries (user message + any partial assistant/tool entries) are still
|
||||
// in the tree file but no longer on the active branch, so they will not
|
||||
// be re-sent to the LLM. runTurn() will append a fresh user message on
|
||||
// the next call.
|
||||
if err := ts.Branch(target.ParentID); err != nil {
|
||||
return "", nil, fmt.Errorf("branch to parent: %w", err)
|
||||
}
|
||||
|
||||
// Sync the in-memory store with the new branch position so subsequent
|
||||
// reads (and ReloadMessagesFromTree() consumers) see the truncated view.
|
||||
a.store.Clear()
|
||||
a.store.Replace(ts.GetLLMMessages())
|
||||
|
||||
return prompt, files, nil
|
||||
}
|
||||
|
||||
// AddContextMessage adds a user-role message to the conversation history
|
||||
// without triggering an LLM response. Used by the ! shell command prefix
|
||||
// to inject command output into context so the LLM can reference it in
|
||||
@@ -356,6 +451,10 @@ func (a *App) AddContextMessage(text string) {
|
||||
// tea.Program. customInstructions is optional text appended to the summary
|
||||
// prompt (e.g. "Focus on the API design decisions").
|
||||
//
|
||||
// Any prompts queued via Run/RunWithFiles or steering messages injected via
|
||||
// Steer/SteerWithFiles while compaction is running are flushed automatically
|
||||
// once compaction completes (see releaseBusyAfterCompact).
|
||||
//
|
||||
// Satisfies ui.AppController.
|
||||
func (a *App) CompactConversation(customInstructions string) error {
|
||||
a.mu.Lock()
|
||||
@@ -377,11 +476,7 @@ func (a *App) CompactConversation(customInstructions string) error {
|
||||
|
||||
go func() {
|
||||
defer a.wg.Done()
|
||||
defer func() {
|
||||
a.mu.Lock()
|
||||
a.busy = false
|
||||
a.mu.Unlock()
|
||||
}()
|
||||
defer a.releaseBusyAfterCompact()
|
||||
|
||||
// Subscribe to SDK events for streaming compaction summary to the TUI.
|
||||
sendFn := func(msg tea.Msg) {
|
||||
@@ -420,6 +515,9 @@ func (a *App) CompactConversation(customInstructions string) error {
|
||||
// CompactAsync is like CompactConversation but calls onComplete/onError
|
||||
// callbacks instead of sending TUI events. Used by the extension API's
|
||||
// ctx.Compact() which needs callback-based notification.
|
||||
//
|
||||
// Like CompactConversation, any prompts/steer messages received during
|
||||
// compaction are flushed automatically once compaction finishes.
|
||||
func (a *App) CompactAsync(customInstructions string, onComplete func(), onError func(string)) error {
|
||||
a.mu.Lock()
|
||||
if a.closed {
|
||||
@@ -440,11 +538,7 @@ func (a *App) CompactAsync(customInstructions string, onComplete func(), onError
|
||||
|
||||
go func() {
|
||||
defer a.wg.Done()
|
||||
defer func() {
|
||||
a.mu.Lock()
|
||||
a.busy = false
|
||||
a.mu.Unlock()
|
||||
}()
|
||||
defer a.releaseBusyAfterCompact()
|
||||
|
||||
// Subscribe to SDK events for streaming compaction summary to the TUI.
|
||||
sendFn := func(msg tea.Msg) {
|
||||
@@ -489,6 +583,81 @@ func (a *App) CompactAsync(customInstructions string, onComplete func(), onError
|
||||
return nil
|
||||
}
|
||||
|
||||
// releaseBusyAfterCompact is the deferred tail that runs at the end of every
|
||||
// compaction goroutine (success, error, or panic-after-recover paths). It
|
||||
// flips a.busy back to false, but before doing so it checks whether any
|
||||
// prompts piled up while compaction was running:
|
||||
//
|
||||
// - Run/RunWithFiles append to a.queue when a.busy is set.
|
||||
// - Steer/SteerWithFiles deposit messages into the SDK steer channel via
|
||||
// Kit.InjectSteerWithFiles when a.busy is set.
|
||||
//
|
||||
// Without this hand-off the queue would sit idle until the user submits
|
||||
// another prompt — see issue #27. If we find anything pending we keep busy
|
||||
// set, splice the steer messages to the front of the queue, and start a
|
||||
// fresh drainQueue goroutine to deliver them as a single batched turn.
|
||||
func (a *App) releaseBusyAfterCompact() {
|
||||
// Pull steer messages outside the app mutex; DrainSteer takes its own
|
||||
// internal lock and we don't want to nest the two. The test seam
|
||||
// (a.steerDrainFn) takes precedence so unit tests can inject fake
|
||||
// steer items without a real *kit.Kit.
|
||||
var steerItems []queueItem
|
||||
switch {
|
||||
case a.steerDrainFn != nil:
|
||||
steerItems = a.steerDrainFn()
|
||||
case a.opts.Kit != nil:
|
||||
if leftover := a.opts.Kit.DrainSteer(); len(leftover) > 0 {
|
||||
steerItems = make([]queueItem, len(leftover))
|
||||
for i, sm := range leftover {
|
||||
steerItems[i] = queueItem{Prompt: sm.Text, Files: sm.Files}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
// If the app was closed while compaction was running, drop everything
|
||||
// and just clear busy. Run/Steer would have rejected new items already
|
||||
// after Close(), but this guards against in-flight items that slipped
|
||||
// in just before closed was set.
|
||||
if a.closed {
|
||||
a.queue = a.queue[:0]
|
||||
a.busy = false
|
||||
a.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// Combine steer-channel items (front) with the in-memory queue (back).
|
||||
// Steer messages are placed first so they retain their "act now"
|
||||
// semantics relative to ordinary queued prompts that arrived later.
|
||||
pending := append(steerItems, a.queue...)
|
||||
a.queue = a.queue[:0]
|
||||
|
||||
if len(pending) == 0 {
|
||||
a.busy = false
|
||||
a.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// Hand off to drainQueue: it will pick up the first item directly and
|
||||
// scoop the rest from a.queue on its first iteration.
|
||||
first := pending[0]
|
||||
if len(pending) > 1 {
|
||||
a.queue = append(a.queue, pending[1:]...)
|
||||
}
|
||||
// Stay busy across the goroutine swap.
|
||||
a.wg.Add(1)
|
||||
a.mu.Unlock()
|
||||
|
||||
// Notify the UI that steer-channel messages were consumed so the
|
||||
// steering badge can clear; ordinary queued prompts will be reflected
|
||||
// by the QueueUpdatedEvent that drainQueue emits as it picks them up.
|
||||
if len(steerItems) > 0 {
|
||||
a.sendEvent(SteerConsumedEvent{})
|
||||
}
|
||||
|
||||
go a.drainQueue(first)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Non-interactive execution
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -888,6 +1057,12 @@ func (a *App) subscribeSDKEvents(sendFn func(tea.Msg), stepUsageSeen *atomic.Boo
|
||||
switch ev := e.(type) {
|
||||
case kit.ToolCallEvent:
|
||||
sendFn(ToolCallStartedEvent{ToolCallID: ev.ToolCallID, ToolName: ev.ToolName, ToolArgs: ev.ToolArgs})
|
||||
case kit.ToolCallStartEvent:
|
||||
sendFn(ToolCallInputStartEvent{ToolCallID: ev.ToolCallID, ToolName: ev.ToolName, ToolKind: ev.ToolKind})
|
||||
case kit.ToolCallDeltaEvent:
|
||||
sendFn(ToolCallInputDeltaEvent{ToolCallID: ev.ToolCallID, Delta: ev.Delta})
|
||||
case kit.ToolCallEndEvent:
|
||||
sendFn(ToolCallInputEndEvent{ToolCallID: ev.ToolCallID})
|
||||
case kit.ToolExecutionStartEvent:
|
||||
sendFn(ToolExecutionEvent{ToolCallID: ev.ToolCallID, ToolName: ev.ToolName, ToolArgs: ev.ToolArgs, IsStarting: true})
|
||||
case kit.ToolExecutionEndEvent:
|
||||
@@ -917,7 +1092,23 @@ func (a *App) subscribeSDKEvents(sendFn func(tea.Msg), stepUsageSeen *atomic.Boo
|
||||
case kit.SteerConsumedEvent:
|
||||
sendFn(SteerConsumedEvent{})
|
||||
case kit.StepUsageEvent:
|
||||
a.recordStepUsage(ev, stepUsageSeen)
|
||||
a.recordStepUsage(ev, stepUsageSeen, sendFn)
|
||||
case kit.PasswordPromptEvent:
|
||||
// Convert SDK PasswordPromptEvent to app PasswordPromptEvent
|
||||
// The TUI will handle this and send the response back
|
||||
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)
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -928,6 +1119,64 @@ func (a *App) subscribeSDKEvents(sendFn func(tea.Msg), stepUsageSeen *atomic.Boo
|
||||
}
|
||||
}
|
||||
|
||||
// handleTurnEnd inspects a turn's final StopReason and surfaces actionable
|
||||
// feedback to the user when the turn ended in a state they can act on.
|
||||
//
|
||||
// Today the only surfaced case is FinishReasonLength — the model hit its
|
||||
// configured max_output_tokens budget and the reply was truncated. Without
|
||||
// this banner the TUI used to swallow the truncation silently, leading to
|
||||
// "ghost" cut-offs with no indication of why.
|
||||
//
|
||||
// Separated from subscribeSDKEvents so tests can exercise it directly via a
|
||||
// stubbed sendFn without standing up a full Kit.
|
||||
func (a *App) handleTurnEnd(ev kit.TurnEndEvent, sendFn func(tea.Msg)) {
|
||||
if sendFn == nil {
|
||||
return
|
||||
}
|
||||
if ev.StopReason != kit.FinishReasonLength {
|
||||
return
|
||||
}
|
||||
sendFn(ExtensionPrintEvent{
|
||||
Level: "info",
|
||||
Text: a.formatMaxTokensTruncatedMessage(),
|
||||
})
|
||||
}
|
||||
|
||||
// formatMaxTokensTruncatedMessage builds the user-facing explanation for a
|
||||
// truncated turn. It reports the active max_output_tokens budget and, when
|
||||
// known, the model's catalog output ceiling so the user can judge how much
|
||||
// headroom is available.
|
||||
func (a *App) formatMaxTokensTruncatedMessage() string {
|
||||
k := a.opts.Kit
|
||||
if k == nil {
|
||||
// Extremely early / test-stub case: still emit a useful generic hint.
|
||||
return "⚠ Response truncated: the model hit the configured max_output_tokens limit. " +
|
||||
"Raise it with --max-tokens N, KIT_MAX_TOKENS=N, or per-model " +
|
||||
"modelSettings[provider/model].maxTokens in config."
|
||||
}
|
||||
current := k.MaxTokens()
|
||||
ceiling := k.MaxOutputLimit()
|
||||
model := k.GetModelString()
|
||||
|
||||
msg := "⚠ Response truncated: "
|
||||
if model != "" {
|
||||
msg += fmt.Sprintf("%s hit the configured max_output_tokens limit", model)
|
||||
} else {
|
||||
msg += "the model hit the configured max_output_tokens limit"
|
||||
}
|
||||
if current > 0 {
|
||||
msg += fmt.Sprintf(" (%d)", current)
|
||||
}
|
||||
msg += "."
|
||||
if ceiling > 0 && current > 0 && ceiling > current {
|
||||
msg += fmt.Sprintf(" This model supports up to %d output tokens.", ceiling)
|
||||
}
|
||||
msg += "\n\nRaise it with --max-tokens N, KIT_MAX_TOKENS=N, " +
|
||||
"or per-model modelSettings[provider/model].maxTokens in your config. " +
|
||||
"Re-run the last prompt after raising it to get the full response."
|
||||
return msg
|
||||
}
|
||||
|
||||
// QuitFromExtension triggers a graceful shutdown. In interactive mode it
|
||||
// sends a tea.QuitMsg to the program so the TUI exits cleanly. In
|
||||
// non-interactive mode it cancels the root context, stopping any in-flight
|
||||
@@ -996,32 +1245,47 @@ func (a *App) NotifyModelChanged(provider, model string) {
|
||||
// extension widgets. Called from the extension context's SetWidget/RemoveWidget
|
||||
// closures. In non-interactive mode this is a no-op (widgets are TUI-only).
|
||||
//
|
||||
// Coalescing: if a WidgetUpdateEvent is already queued and not yet consumed
|
||||
// by the TUI event loop, additional calls within the same ~16 ms window are
|
||||
// dropped. This prevents fast extension tickers from flooding BubbleTea's
|
||||
// mailbox with redundant re-render triggers.
|
||||
// Coalescing (leading + trailing edge): the first call in an idle period
|
||||
// fires immediately for responsiveness. Subsequent calls within a ~16 ms
|
||||
// debounce window are batched into a single trailing event delivered at
|
||||
// the end of the window. The trailing send is essential for correctness:
|
||||
// extensions routinely make tight SetWidget→RemoveWidget pairs (e.g. on
|
||||
// SubagentEnd) and silently dropping the second call would leave the TUI's
|
||||
// layout stuck on stale widget dimensions until some other event happens
|
||||
// to trigger a re-render.
|
||||
func (a *App) NotifyWidgetUpdate() {
|
||||
// Coalesce: only one pending update at a time.
|
||||
if !a.widgetUpdatePending.CompareAndSwap(false, true) {
|
||||
// A leading-edge event is already in flight — mark that the runner
|
||||
// state has changed again so the trailing send below picks it up.
|
||||
a.widgetUpdateTrailing.Store(true)
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
prog := a.program
|
||||
a.mu.Unlock()
|
||||
if prog != nil {
|
||||
prog.Send(WidgetUpdateEvent{})
|
||||
// Reset the pending flag after a short debounce so subsequent calls
|
||||
// within the same render cycle are also coalesced, but new updates
|
||||
// after the cycle are allowed through.
|
||||
go func() {
|
||||
time.Sleep(16 * time.Millisecond) // ~1 frame at 60 fps
|
||||
a.widgetUpdatePending.Store(false)
|
||||
}()
|
||||
} else {
|
||||
if prog == nil {
|
||||
// No program registered (non-interactive mode); clear the flag so
|
||||
// future calls are never permanently blocked.
|
||||
a.widgetUpdatePending.Store(false)
|
||||
return
|
||||
}
|
||||
prog.Send(WidgetUpdateEvent{})
|
||||
go func() {
|
||||
time.Sleep(16 * time.Millisecond) // ~1 frame at 60 fps
|
||||
// If any extra calls came in during the debounce window, deliver
|
||||
// one trailing event so the TUI sees the latest widget state. We
|
||||
// swap-and-test instead of plain-load so concurrent calls after
|
||||
// the trailing send still race correctly with the pending reset.
|
||||
if a.widgetUpdateTrailing.Swap(false) {
|
||||
a.mu.Lock()
|
||||
p := a.program
|
||||
a.mu.Unlock()
|
||||
if p != nil {
|
||||
p.Send(WidgetUpdateEvent{})
|
||||
}
|
||||
}
|
||||
a.widgetUpdatePending.Store(false)
|
||||
}()
|
||||
}
|
||||
|
||||
// NotifyContentReload sends a ContentReloadEvent to the TUI so it refreshes
|
||||
@@ -1161,7 +1425,16 @@ func (a *App) PrintBlockFromExtension(opts extensions.PrintBlockOpts) {
|
||||
// recordStepUsage applies token/cost usage reported for a completed step.
|
||||
// Step usage events arrive even when a turn is later cancelled, so this keeps
|
||||
// the usage widget accurate on all stop paths.
|
||||
func (a *App) recordStepUsage(ev kit.StepUsageEvent, stepUsageSeen *atomic.Bool) {
|
||||
//
|
||||
// Both session totals (cost, token counts) and the context window fill level
|
||||
// are updated here so the status bar reflects progress after every LLM call,
|
||||
// not just at the end of the full turn. Context fill monotonically increases
|
||||
// across steps because each step re-sends the entire conversation plus any
|
||||
// new tool results, so the numbers only go up.
|
||||
//
|
||||
// sendFn is called with a UsageUpdatedEvent to trigger a TUI re-render so
|
||||
// the updated values are visible immediately.
|
||||
func (a *App) recordStepUsage(ev kit.StepUsageEvent, stepUsageSeen *atomic.Bool, sendFn func(tea.Msg)) {
|
||||
hasUsage := ev.InputTokens > 0 || ev.OutputTokens > 0 || ev.CacheReadTokens > 0 || ev.CacheWriteTokens > 0
|
||||
if a.opts.Debug {
|
||||
log.Printf("[DEBUG] recordStepUsage: hasUsage=%v input=%d output=%d cacheRead=%d cacheWrite=%d",
|
||||
@@ -1182,11 +1455,21 @@ func (a *App) recordStepUsage(ev kit.StepUsageEvent, stepUsageSeen *atomic.Bool)
|
||||
int(ev.CacheReadTokens),
|
||||
int(ev.CacheWriteTokens),
|
||||
)
|
||||
// NOTE: We do NOT call SetContextTokens here. Context fill is set once
|
||||
// at turn completion via updateUsageFromTurnResult, which sums all token
|
||||
// categories (Input + CacheRead + CacheCreate + Output) from FinalUsage.
|
||||
// Per-step context tokens would cause the display to jump around during
|
||||
// multi-step tool calls.
|
||||
// Update context window fill from this step's usage. Each step sends
|
||||
// the full conversation to the LLM, so the reported token counts
|
||||
// represent the actual context utilization at that point.
|
||||
contextFill := int(ev.InputTokens) + int(ev.CacheReadTokens) + int(ev.CacheWriteTokens) + int(ev.OutputTokens)
|
||||
if contextFill > 0 {
|
||||
if a.opts.Debug {
|
||||
log.Printf("[DEBUG] recordStepUsage: SetContextTokens=%d (Input=%d + CacheRead=%d + CacheWrite=%d + Output=%d)",
|
||||
contextFill, ev.InputTokens, ev.CacheReadTokens, ev.CacheWriteTokens, ev.OutputTokens)
|
||||
}
|
||||
a.opts.UsageTracker.SetContextTokens(contextFill)
|
||||
}
|
||||
// Notify the TUI so it re-renders the status bar with updated values.
|
||||
if sendFn != nil {
|
||||
sendFn(UsageUpdatedEvent{})
|
||||
}
|
||||
}
|
||||
|
||||
// updateUsageFromTurnResult records token usage from an SDK TurnResult into the
|
||||
|
||||
+456
-7
@@ -3,11 +3,16 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/fantasy"
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
|
||||
"github.com/mark3labs/kit/internal/session"
|
||||
)
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -532,9 +537,9 @@ func TestQueueLength_reflects(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestRecordStepUsage_updatesTracker verifies that per-step usage updates are
|
||||
// recorded immediately for cost tracking. Context tokens are NOT updated here
|
||||
// (only via updateUsageFromTurnResult) to avoid display jumps during multi-step
|
||||
// tool calls.
|
||||
// recorded immediately for cost tracking. Context tokens are also updated so
|
||||
// the status bar reflects context fill after every LLM call in a multi-step
|
||||
// turn, not just at the end.
|
||||
func TestRecordStepUsage_updatesTracker(t *testing.T) {
|
||||
usage := &usageUpdaterStub{}
|
||||
app := New(Options{UsageTracker: usage}, nil)
|
||||
@@ -545,7 +550,7 @@ func TestRecordStepUsage_updatesTracker(t *testing.T) {
|
||||
OutputTokens: 45,
|
||||
CacheReadTokens: 5,
|
||||
CacheWriteTokens: 2,
|
||||
}, nil)
|
||||
}, nil, nil)
|
||||
|
||||
usage.mu.Lock()
|
||||
defer usage.mu.Unlock()
|
||||
@@ -557,9 +562,13 @@ func TestRecordStepUsage_updatesTracker(t *testing.T) {
|
||||
t.Fatalf("unexpected usage update payload: in=%d out=%d cache_read=%d cache_write=%d",
|
||||
usage.lastUpdateInput, usage.lastUpdateOutput, usage.lastUpdateCacheRead, usage.lastUpdateCacheWrite)
|
||||
}
|
||||
// Context tokens should NOT be updated by recordStepUsage (only by updateUsageFromTurnResult)
|
||||
if usage.contextCalls != 0 {
|
||||
t.Fatalf("expected 0 context token updates from recordStepUsage, got %d", usage.contextCalls)
|
||||
// Context tokens should now be updated per-step (Input + CacheRead + CacheWrite + Output).
|
||||
if usage.contextCalls != 1 {
|
||||
t.Fatalf("expected 1 context token update from recordStepUsage, got %d", usage.contextCalls)
|
||||
}
|
||||
expectedContext := 120 + 45 + 5 + 2
|
||||
if usage.lastContextTokens != expectedContext {
|
||||
t.Fatalf("expected context tokens %d, got %d", expectedContext, usage.lastContextTokens)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -666,3 +675,443 @@ func TestUpdateUsageFromTurnResult_contextTokensUsesAllCategories(t *testing.T)
|
||||
expected, usage.contextCalls, usage.lastContextTokens)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleTurnEnd_LengthEmitsWarning verifies that when the SDK reports a
|
||||
// FinishReasonLength (max_output_tokens hit), the app surfaces a user-visible
|
||||
// ExtensionPrintEvent with Level="info" so the TUI can render a banner
|
||||
// instead of silently showing a truncated reply.
|
||||
func TestHandleTurnEnd_LengthEmitsWarning(t *testing.T) {
|
||||
app := New(Options{}, nil)
|
||||
defer app.Close()
|
||||
|
||||
var mu sync.Mutex
|
||||
var received []tea.Msg
|
||||
sendFn := func(m tea.Msg) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
received = append(received, m)
|
||||
}
|
||||
|
||||
app.handleTurnEnd(kit.TurnEndEvent{StopReason: kit.FinishReasonLength}, sendFn)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(received) != 1 {
|
||||
t.Fatalf("expected 1 event on length stop, got %d", len(received))
|
||||
}
|
||||
ev, ok := received[0].(ExtensionPrintEvent)
|
||||
if !ok {
|
||||
t.Fatalf("expected ExtensionPrintEvent, got %T", received[0])
|
||||
}
|
||||
if ev.Level != "info" {
|
||||
t.Errorf("expected Level=info, got %q", ev.Level)
|
||||
}
|
||||
if ev.Text == "" {
|
||||
t.Error("expected non-empty warning text")
|
||||
}
|
||||
if !strings.Contains(ev.Text, "max_output_tokens") {
|
||||
t.Errorf("warning text should mention max_output_tokens, got: %s", ev.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleTurnEnd_NonLengthIgnored verifies that ordinary stop reasons
|
||||
// (stop, tool-calls, error, unknown, "") do not produce a warning banner.
|
||||
func TestHandleTurnEnd_NonLengthIgnored(t *testing.T) {
|
||||
app := New(Options{}, nil)
|
||||
defer app.Close()
|
||||
|
||||
reasons := []string{
|
||||
kit.FinishReasonStop,
|
||||
kit.FinishReasonToolCalls,
|
||||
kit.FinishReasonError,
|
||||
kit.FinishReasonContentFilter,
|
||||
kit.FinishReasonOther,
|
||||
kit.FinishReasonUnknown,
|
||||
"",
|
||||
}
|
||||
for _, r := range reasons {
|
||||
var called bool
|
||||
app.handleTurnEnd(kit.TurnEndEvent{StopReason: r}, func(m tea.Msg) {
|
||||
called = true
|
||||
})
|
||||
if called {
|
||||
t.Errorf("stop reason %q unexpectedly emitted a warning", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleTurnEnd_NilSendFn guards against panics when no TUI listener is
|
||||
// attached (e.g. early init or headless teardown).
|
||||
func TestHandleTurnEnd_NilSendFn(t *testing.T) {
|
||||
app := New(Options{}, nil)
|
||||
defer app.Close()
|
||||
|
||||
// Should not panic with a nil sendFn.
|
||||
app.handleTurnEnd(kit.TurnEndEvent{StopReason: kit.FinishReasonLength}, nil)
|
||||
}
|
||||
|
||||
// TestFormatMaxTokensTruncatedMessage_NoKit verifies the fallback message
|
||||
// when Options.Kit is nil (test/stub path).
|
||||
func TestFormatMaxTokensTruncatedMessage_NoKit(t *testing.T) {
|
||||
app := New(Options{}, nil)
|
||||
defer app.Close()
|
||||
|
||||
msg := app.formatMaxTokensTruncatedMessage()
|
||||
if msg == "" {
|
||||
t.Fatal("expected non-empty fallback message")
|
||||
}
|
||||
for _, needle := range []string{"max_output_tokens", "--max-tokens", "KIT_MAX_TOKENS", "modelSettings"} {
|
||||
if !strings.Contains(msg, needle) {
|
||||
t.Errorf("fallback message missing %q:\n%s", needle, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// releaseBusyAfterCompact (issue #27)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// TestReleaseBusyAfterCompact_flushesQueuedMessages is a regression test for
|
||||
// issue #27: messages queued via Run() while /compact is running used to sit
|
||||
// in a.queue indefinitely until the user typed another prompt. After the fix
|
||||
// the deferred releaseBusyAfterCompact tail picks up any pending items and
|
||||
// dispatches drainQueue automatically.
|
||||
//
|
||||
// We simulate the compaction completion path directly (bypassing the SDK)
|
||||
// by toggling busy=true, populating the queue exactly as Run() would have
|
||||
// during compaction, and then invoking releaseBusyAfterCompact.
|
||||
func TestReleaseBusyAfterCompact_flushesQueuedMessages(t *testing.T) {
|
||||
stub := newStubWithFuncs(
|
||||
func(ctx context.Context) (*kit.TurnResult, error) {
|
||||
return turnResult("compacted then drained"), nil
|
||||
},
|
||||
)
|
||||
app := newTestApp(stub)
|
||||
defer app.Close()
|
||||
|
||||
// Simulate the state at the start of the compaction tail: busy is set
|
||||
// and a couple of prompts have piled up in the queue while we were
|
||||
// summarising. (Run() would have appended them and returned a queue
|
||||
// length > 0 to the caller.)
|
||||
app.mu.Lock()
|
||||
app.busy = true
|
||||
app.queue = append(app.queue,
|
||||
queueItem{Prompt: "queued during compact #1"},
|
||||
queueItem{Prompt: "queued during compact #2"},
|
||||
)
|
||||
app.mu.Unlock()
|
||||
|
||||
// Invoke the deferred tail directly. It should kick off drainQueue.
|
||||
app.releaseBusyAfterCompact()
|
||||
|
||||
// drainQueue runs in a goroutine. Wait for the app to come back to idle.
|
||||
ok := waitForCondition(2*time.Second, func() bool {
|
||||
app.mu.Lock()
|
||||
defer app.mu.Unlock()
|
||||
return !app.busy
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("app did not become idle after releaseBusyAfterCompact: queue not drained")
|
||||
}
|
||||
|
||||
// Wait for any in-flight goroutine to finish before reading state.
|
||||
app.wg.Wait()
|
||||
|
||||
if got := app.QueueLength(); got != 0 {
|
||||
t.Fatalf("expected empty queue after drain, got %d", got)
|
||||
}
|
||||
if n := stub.callCount(); n == 0 {
|
||||
t.Fatalf("expected stub PromptFunc to fire at least once after compact, got %d calls", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReleaseBusyAfterCompact_idleWhenQueueEmpty verifies that with no
|
||||
// pending messages the helper just clears busy and does NOT spawn a
|
||||
// drainQueue goroutine (no spurious agent turn).
|
||||
func TestReleaseBusyAfterCompact_idleWhenQueueEmpty(t *testing.T) {
|
||||
stub := newStub()
|
||||
app := newTestApp(stub)
|
||||
defer app.Close()
|
||||
|
||||
app.mu.Lock()
|
||||
app.busy = true
|
||||
app.mu.Unlock()
|
||||
|
||||
app.releaseBusyAfterCompact()
|
||||
|
||||
app.mu.Lock()
|
||||
busy := app.busy
|
||||
app.mu.Unlock()
|
||||
if busy {
|
||||
t.Fatal("expected busy=false after releaseBusyAfterCompact with empty queue")
|
||||
}
|
||||
|
||||
// Give any rogue goroutine a moment to (incorrectly) call PromptFunc.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if n := stub.callCount(); n != 0 {
|
||||
t.Fatalf("expected 0 PromptFunc calls when queue empty, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReleaseBusyAfterCompact_splicesSteerAheadOfQueue exercises the SDK
|
||||
// steer-drain branch of releaseBusyAfterCompact (issue #27 follow-up).
|
||||
//
|
||||
// Production wires a.opts.Kit.DrainSteer() to pull messages that arrived via
|
||||
// Steer/SteerWithFiles during compaction, but Options.Kit is *kit.Kit (a
|
||||
// concrete struct) so unit tests cannot stand up a real instance without a
|
||||
// full LLM backend. The test uses the unexported steerDrainFn seam to inject
|
||||
// fake steer items, then asserts that:
|
||||
//
|
||||
// - Steer items are dispatched ahead of any prompts that piled up in
|
||||
// a.queue (steer retains "act now" priority over ordinary queued
|
||||
// prompts), and
|
||||
// - the helper still hands off to drainQueue so the steer item actually
|
||||
// fires (the previous behaviour left them stranded — see #27).
|
||||
func TestReleaseBusyAfterCompact_splicesSteerAheadOfQueue(t *testing.T) {
|
||||
var pmu sync.Mutex
|
||||
var firstPrompt string
|
||||
stub := newStubWithFuncs(
|
||||
func(ctx context.Context) (*kit.TurnResult, error) {
|
||||
return turnResult("steer dispatched"), nil
|
||||
},
|
||||
)
|
||||
// Wrap PromptFunc so we can capture the prompt text the stub receives
|
||||
// (newStubWithFuncs's fns ignore prompt; we need it to verify ordering).
|
||||
capturingPrompt := func(ctx context.Context, prompt string) (*kit.TurnResult, error) {
|
||||
pmu.Lock()
|
||||
if firstPrompt == "" {
|
||||
firstPrompt = prompt
|
||||
}
|
||||
pmu.Unlock()
|
||||
return stub.fn(ctx, prompt)
|
||||
}
|
||||
app := New(Options{PromptFunc: capturingPrompt}, nil)
|
||||
defer app.Close()
|
||||
|
||||
// Inject fake steer items via the test seam. In production the same
|
||||
// items would have been delivered through Kit.InjectSteerWithFiles
|
||||
// during /compact and pulled by DrainSteer here.
|
||||
app.steerDrainFn = func() []queueItem {
|
||||
return []queueItem{
|
||||
{Prompt: "steer-1"},
|
||||
{Prompt: "steer-2"},
|
||||
}
|
||||
}
|
||||
|
||||
// Simulate the state at the end of compaction: busy is set and a couple
|
||||
// of regular Run() prompts have piled up after the steer messages.
|
||||
app.mu.Lock()
|
||||
app.busy = true
|
||||
app.queue = append(app.queue,
|
||||
queueItem{Prompt: "queued-1"},
|
||||
queueItem{Prompt: "queued-2"},
|
||||
)
|
||||
app.mu.Unlock()
|
||||
|
||||
app.releaseBusyAfterCompact()
|
||||
|
||||
// Wait for the dispatched batch to complete.
|
||||
ok := waitForCondition(2*time.Second, func() bool {
|
||||
app.mu.Lock()
|
||||
defer app.mu.Unlock()
|
||||
return !app.busy
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("app did not become idle after steer-spliced releaseBusyAfterCompact")
|
||||
}
|
||||
app.wg.Wait()
|
||||
|
||||
// drainQueue picks up `first` directly and batches the rest. With
|
||||
// PromptFunc set, executeBatch invokes us with items[0] only — that
|
||||
// item must be the first steer message, proving steer items were
|
||||
// spliced ahead of the previously queued prompts.
|
||||
pmu.Lock()
|
||||
got := firstPrompt
|
||||
pmu.Unlock()
|
||||
if got != "steer-1" {
|
||||
t.Fatalf("expected first dispatched prompt to be steer item %q (steer items must come before queued prompts), got %q",
|
||||
"steer-1", got)
|
||||
}
|
||||
|
||||
// Queue should be fully drained and PromptFunc must have actually fired.
|
||||
if n := app.QueueLength(); n != 0 {
|
||||
t.Fatalf("expected empty queue after drain, got %d entries", n)
|
||||
}
|
||||
if n := stub.callCount(); n == 0 {
|
||||
t.Fatal("expected stub PromptFunc to fire at least once after splice")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReleaseBusyAfterCompact_dropsQueueWhenClosed verifies that if the app
|
||||
// was closed during compaction the helper discards any pending items rather
|
||||
// than spawning drainQueue against a torn-down App.
|
||||
func TestReleaseBusyAfterCompact_dropsQueueWhenClosed(t *testing.T) {
|
||||
stub := newStub()
|
||||
app := newTestApp(stub)
|
||||
|
||||
app.mu.Lock()
|
||||
app.busy = true
|
||||
app.queue = append(app.queue, queueItem{Prompt: "would have run"})
|
||||
app.closed = true
|
||||
app.mu.Unlock()
|
||||
|
||||
app.releaseBusyAfterCompact()
|
||||
|
||||
app.mu.Lock()
|
||||
busy := app.busy
|
||||
qLen := len(app.queue)
|
||||
app.mu.Unlock()
|
||||
if busy {
|
||||
t.Fatal("expected busy=false even when closed")
|
||||
}
|
||||
if qLen != 0 {
|
||||
t.Fatalf("expected queue cleared on closed app, got %d entries", qLen)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if n := stub.callCount(); n != 0 {
|
||||
t.Fatalf("expected 0 PromptFunc calls on closed app, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// PopLastUserMessage (/retry building block)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// TestPopLastUserMessage_NoTreeSession verifies that PopLastUserMessage
|
||||
// returns an error when no tree session is active.
|
||||
func TestPopLastUserMessage_NoTreeSession(t *testing.T) {
|
||||
app := newTestApp(newStub())
|
||||
defer app.Close()
|
||||
|
||||
prompt, files, err := app.PopLastUserMessage()
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no tree session is active")
|
||||
}
|
||||
if prompt != "" || files != nil {
|
||||
t.Fatalf("expected zero values on error, got prompt=%q files=%v", prompt, files)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPopLastUserMessage_WhileBusy verifies that PopLastUserMessage
|
||||
// refuses to truncate while the agent is busy (would race with executeBatch).
|
||||
func TestPopLastUserMessage_WhileBusy(t *testing.T) {
|
||||
app := newTestApp(newStub())
|
||||
defer app.Close()
|
||||
|
||||
app.mu.Lock()
|
||||
app.busy = true
|
||||
app.mu.Unlock()
|
||||
|
||||
_, _, err := app.PopLastUserMessage()
|
||||
if err == nil {
|
||||
t.Fatal("expected error when agent is busy")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "working") {
|
||||
t.Fatalf("expected error mentioning busy/working, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestPopLastUserMessage_WhenClosed verifies that PopLastUserMessage
|
||||
// returns an error after Close().
|
||||
func TestPopLastUserMessage_WhenClosed(t *testing.T) {
|
||||
app := newTestApp(newStub())
|
||||
app.Close()
|
||||
|
||||
_, _, err := app.PopLastUserMessage()
|
||||
if err == nil {
|
||||
t.Fatal("expected error on closed app")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPopLastUserMessage_TruncatesAndReturnsPrompt verifies the happy path:
|
||||
// a real tree session with user→assistant→user→assistant entries is
|
||||
// truncated back to before the most recent user message, and that user's
|
||||
// text is returned.
|
||||
func TestPopLastUserMessage_TruncatesAndReturnsPrompt(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ts, err := session.CreateTreeSession(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("create tree session: %v", err)
|
||||
}
|
||||
defer func() { _ = ts.Close() }()
|
||||
|
||||
// Build history: user "first" → assistant "ack 1" → user "second" → assistant "ack 2".
|
||||
if _, err := ts.AppendLLMMessage(fantasy.NewUserMessage("first")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ts.AppendLLMMessage(fantasy.Message{
|
||||
Role: fantasy.MessageRoleAssistant,
|
||||
Content: []fantasy.MessagePart{fantasy.TextPart{Text: "ack 1"}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ts.AppendLLMMessage(fantasy.NewUserMessage("second")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ts.AppendLLMMessage(fantasy.Message{
|
||||
Role: fantasy.MessageRoleAssistant,
|
||||
Content: []fantasy.MessagePart{fantasy.TextPart{Text: "ack 2"}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app := New(Options{TreeSession: ts, PromptFunc: newStub().fn}, nil)
|
||||
defer app.Close()
|
||||
|
||||
prompt, files, err := app.PopLastUserMessage()
|
||||
if err != nil {
|
||||
t.Fatalf("PopLastUserMessage: %v", err)
|
||||
}
|
||||
if prompt != "second" {
|
||||
t.Fatalf("expected prompt=%q, got %q", "second", prompt)
|
||||
}
|
||||
if files != nil {
|
||||
t.Fatalf("expected no files, got %v", files)
|
||||
}
|
||||
|
||||
// After truncation the branch should only contain the first user
|
||||
// message and its assistant response (the "second" turn is orphaned).
|
||||
msgs := ts.GetLLMMessages()
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("expected 2 messages on truncated branch, got %d", len(msgs))
|
||||
}
|
||||
if got := messageText(msgs[0]); got != "first" {
|
||||
t.Fatalf("expected first message %q, got %q", "first", got)
|
||||
}
|
||||
if got := messageText(msgs[1]); got != "ack 1" {
|
||||
t.Fatalf("expected second message %q, got %q", "ack 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// messageText extracts concatenated TextPart content from a fantasy.Message.
|
||||
func messageText(m fantasy.Message) string {
|
||||
var out strings.Builder
|
||||
for _, p := range m.Content {
|
||||
if tp, ok := p.(fantasy.TextPart); ok {
|
||||
out.WriteString(tp.Text)
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// TestPopLastUserMessage_NoUserOnBranch verifies that an empty tree (no
|
||||
// user messages at all) returns a friendly error rather than panicking.
|
||||
func TestPopLastUserMessage_NoUserOnBranch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ts, err := session.CreateTreeSession(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("create tree session: %v", err)
|
||||
}
|
||||
defer func() { _ = ts.Close() }()
|
||||
|
||||
app := New(Options{TreeSession: ts, PromptFunc: newStub().fn}, nil)
|
||||
defer app.Close()
|
||||
|
||||
_, _, err = app.PopLastUserMessage()
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no user message exists on branch")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no user message") {
|
||||
t.Fatalf("expected error mentioning missing user message, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,36 @@ type ToolCallStartedEvent struct {
|
||||
ToolArgs string
|
||||
}
|
||||
|
||||
// ToolCallInputStartEvent is sent when the LLM begins generating tool call
|
||||
// arguments. The tool name is known but the full argument JSON is still being
|
||||
// streamed. UIs can use this to show a "running" indicator immediately instead
|
||||
// of waiting for the full argument JSON to finish streaming.
|
||||
type ToolCallInputStartEvent struct {
|
||||
// ToolCallID is the stable identifier for correlating tool lifecycle events.
|
||||
ToolCallID string
|
||||
// ToolName is the name of the tool being called.
|
||||
ToolName string
|
||||
// ToolKind classifies the tool: "execute", "edit", "read", "search", "agent".
|
||||
ToolKind string
|
||||
}
|
||||
|
||||
// ToolCallInputDeltaEvent is sent for each streamed fragment of tool call
|
||||
// arguments as they arrive from the LLM. Useful for live-previewing content
|
||||
// or showing a progress indicator with byte count.
|
||||
type ToolCallInputDeltaEvent struct {
|
||||
// ToolCallID is the stable identifier for correlating tool lifecycle events.
|
||||
ToolCallID string
|
||||
// Delta is a JSON fragment of tool call arguments.
|
||||
Delta string
|
||||
}
|
||||
|
||||
// ToolCallInputEndEvent is sent when tool argument streaming is complete,
|
||||
// before the tool call is parsed and execution begins.
|
||||
type ToolCallInputEndEvent struct {
|
||||
// ToolCallID is the stable identifier for correlating tool lifecycle events.
|
||||
ToolCallID string
|
||||
}
|
||||
|
||||
// ToolExecutionEvent is sent when a tool starts or finishes executing.
|
||||
// The IsStarting flag distinguishes between the start and end of execution.
|
||||
type ToolExecutionEvent struct {
|
||||
@@ -79,6 +109,24 @@ 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 {
|
||||
@@ -162,6 +210,12 @@ type ModelChangedEvent struct {
|
||||
ModelName string
|
||||
}
|
||||
|
||||
// UsageUpdatedEvent is sent after each completed LLM step to notify the TUI
|
||||
// that token counts and costs have changed. The UsageTracker is updated
|
||||
// in-place before this event is sent; the TUI just needs to re-render to
|
||||
// reflect the new values in the status bar.
|
||||
type UsageUpdatedEvent struct{}
|
||||
|
||||
// WidgetUpdateEvent is sent when an extension adds, updates, or removes a
|
||||
// widget via ctx.SetWidget or ctx.RemoveWidget. The TUI re-reads widget state
|
||||
// from its WidgetProvider on the next render cycle.
|
||||
|
||||
@@ -13,11 +13,6 @@ type MessageStore struct {
|
||||
messages []kit.LLMMessage
|
||||
}
|
||||
|
||||
// NewMessageStore creates an empty MessageStore.
|
||||
func NewMessageStore() *MessageStore {
|
||||
return &MessageStore{}
|
||||
}
|
||||
|
||||
// NewMessageStoreWithMessages creates a MessageStore pre-populated with the
|
||||
// given messages. This is used when loading an existing session at startup.
|
||||
func NewMessageStoreWithMessages(msgs []kit.LLMMessage) *MessageStore {
|
||||
|
||||
@@ -29,7 +29,7 @@ func textOf(msg kit.LLMMessage) string {
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func TestNewMessageStore_empty(t *testing.T) {
|
||||
s := NewMessageStore()
|
||||
s := NewMessageStoreWithMessages(nil)
|
||||
if s == nil {
|
||||
t.Fatal("expected non-nil store")
|
||||
}
|
||||
@@ -72,7 +72,7 @@ func TestNewMessageStoreWithMessages_isolatesInput(t *testing.T) {
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func TestAdd_appendsMessage(t *testing.T) {
|
||||
s := NewMessageStore()
|
||||
s := NewMessageStoreWithMessages(nil)
|
||||
s.Add(makeTextMsg("user", "first"))
|
||||
s.Add(makeTextMsg("assistant", "second"))
|
||||
|
||||
@@ -82,7 +82,7 @@ func TestAdd_appendsMessage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdd_preservesOrder(t *testing.T) {
|
||||
s := NewMessageStore()
|
||||
s := NewMessageStoreWithMessages(nil)
|
||||
texts := []string{"a", "b", "c"}
|
||||
for _, t2 := range texts {
|
||||
s.Add(makeTextMsg("user", t2))
|
||||
@@ -100,7 +100,7 @@ func TestAdd_preservesOrder(t *testing.T) {
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func TestReplace_swapsHistory(t *testing.T) {
|
||||
s := NewMessageStore()
|
||||
s := NewMessageStoreWithMessages(nil)
|
||||
s.Add(makeTextMsg("user", "old"))
|
||||
|
||||
replacement := []kit.LLMMessage{
|
||||
@@ -120,7 +120,7 @@ func TestReplace_swapsHistory(t *testing.T) {
|
||||
|
||||
// Replace must deep-copy the incoming slice.
|
||||
func TestReplace_isolatesInput(t *testing.T) {
|
||||
s := NewMessageStore()
|
||||
s := NewMessageStoreWithMessages(nil)
|
||||
replacement := []kit.LLMMessage{makeTextMsg("user", "original")}
|
||||
s.Replace(replacement)
|
||||
|
||||
@@ -137,7 +137,7 @@ func TestReplace_isolatesInput(t *testing.T) {
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func TestGetAll_returnsCopy(t *testing.T) {
|
||||
s := NewMessageStore()
|
||||
s := NewMessageStoreWithMessages(nil)
|
||||
s.Add(makeTextMsg("user", "hello"))
|
||||
|
||||
got := s.GetAll()
|
||||
@@ -151,7 +151,7 @@ func TestGetAll_returnsCopy(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetAll_emptyStore(t *testing.T) {
|
||||
s := NewMessageStore()
|
||||
s := NewMessageStoreWithMessages(nil)
|
||||
got := s.GetAll()
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("expected empty slice, got %d elements", len(got))
|
||||
@@ -163,7 +163,7 @@ func TestGetAll_emptyStore(t *testing.T) {
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func TestClear_removesAllMessages(t *testing.T) {
|
||||
s := NewMessageStore()
|
||||
s := NewMessageStoreWithMessages(nil)
|
||||
s.Add(makeTextMsg("user", "a"))
|
||||
s.Add(makeTextMsg("user", "b"))
|
||||
s.Clear()
|
||||
@@ -174,7 +174,7 @@ func TestClear_removesAllMessages(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestClear_allowsSubsequentAdds(t *testing.T) {
|
||||
s := NewMessageStore()
|
||||
s := NewMessageStoreWithMessages(nil)
|
||||
s.Add(makeTextMsg("user", "before"))
|
||||
s.Clear()
|
||||
s.Add(makeTextMsg("user", "after"))
|
||||
@@ -193,7 +193,7 @@ func TestClear_allowsSubsequentAdds(t *testing.T) {
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func TestConcurrentAccess(t *testing.T) {
|
||||
s := NewMessageStore()
|
||||
s := NewMessageStoreWithMessages(nil)
|
||||
done := make(chan struct{})
|
||||
|
||||
// Writer goroutine.
|
||||
|
||||
+143
-45
@@ -1,6 +1,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -9,11 +10,11 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// CredentialStore holds all stored credentials for various providers.
|
||||
// Currently supports Anthropic and OpenAI credentials with both OAuth and API key authentication methods.
|
||||
// CredentialStore holds stored credentials for Anthropic, OpenAI, and GitHub Copilot.
|
||||
type CredentialStore struct {
|
||||
Anthropic *AnthropicCredentials `json:"anthropic,omitempty"`
|
||||
OpenAI *OpenAICredentials `json:"openai,omitempty"`
|
||||
Copilot *CopilotCredentials `json:"copilot,omitempty"`
|
||||
}
|
||||
|
||||
// AnthropicCredentials holds Anthropic API credentials supporting both OAuth
|
||||
@@ -43,6 +44,16 @@ type OpenAICredentials struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// CopilotCredentials holds GitHub OAuth credentials and the short-lived
|
||||
// GitHub Copilot API token derived from them.
|
||||
type CopilotCredentials struct {
|
||||
Type string `json:"type"` // "oauth"
|
||||
GitHubToken string `json:"github_token,omitempty"` // GitHub device-flow OAuth token
|
||||
CopilotAccessToken string `json:"copilot_access_token,omitempty"` // Short-lived Copilot API token
|
||||
ExpiresAt int64 `json:"expires_at,omitempty"` // Copilot token expiry
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// oauthTokenExpired reports whether an OAuth token with the given type and
|
||||
// expiry unix timestamp is past its expiry. Returns false for API key
|
||||
// credentials or when no expiry is set.
|
||||
@@ -91,6 +102,16 @@ func (c *OpenAICredentials) NeedsRefresh() bool {
|
||||
return oauthTokenNeedsRefresh(c.Type, c.ExpiresAt)
|
||||
}
|
||||
|
||||
// IsExpired checks if the Copilot API token is expired.
|
||||
func (c *CopilotCredentials) IsExpired() bool {
|
||||
return oauthTokenExpired(c.Type, c.ExpiresAt)
|
||||
}
|
||||
|
||||
// NeedsRefresh reports whether the Copilot API token should be renewed.
|
||||
func (c *CopilotCredentials) NeedsRefresh() bool {
|
||||
return oauthTokenNeedsRefresh(c.Type, c.ExpiresAt)
|
||||
}
|
||||
|
||||
// CredentialManager handles secure storage and retrieval of authentication credentials.
|
||||
// It manages a JSON file stored in the user's config directory with appropriate
|
||||
// file permissions for security.
|
||||
@@ -222,7 +243,7 @@ func (cm *CredentialManager) RemoveAnthropicCredentials() error {
|
||||
store.Anthropic = nil
|
||||
|
||||
// If store is empty, remove the file entirely
|
||||
if store.Anthropic == nil {
|
||||
if store.Anthropic == nil && store.OpenAI == nil && store.Copilot == nil {
|
||||
if err := os.Remove(cm.credentialsPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to remove credentials file: %w", err)
|
||||
}
|
||||
@@ -255,29 +276,6 @@ func (cm *CredentialManager) HasAnthropicCredentials() (bool, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetOpenAICredentials stores OpenAI API key credentials. It validates the
|
||||
// API key format before storing. The API key must start with "sk-" and be
|
||||
// at least 20 characters long. Returns an error if the API key is invalid or
|
||||
// if storage fails.
|
||||
func (cm *CredentialManager) SetOpenAICredentials(apiKey string) error {
|
||||
if err := validateOpenAIAPIKey(apiKey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
store, err := cm.LoadCredentials()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
store.OpenAI = &OpenAICredentials{
|
||||
Type: "api_key",
|
||||
APIKey: apiKey,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
return cm.SaveCredentials(store)
|
||||
}
|
||||
|
||||
// GetOpenAICredentials retrieves stored OpenAI credentials. Returns nil if
|
||||
// no credentials are stored. The returned credentials may be either OAuth or API
|
||||
// key type, check the Type field to determine which.
|
||||
@@ -302,7 +300,7 @@ func (cm *CredentialManager) RemoveOpenAICredentials() error {
|
||||
store.OpenAI = nil
|
||||
|
||||
// If store is empty, remove the file entirely
|
||||
if store.Anthropic == nil && store.OpenAI == nil {
|
||||
if store.Anthropic == nil && store.OpenAI == nil && store.Copilot == nil {
|
||||
if err := os.Remove(cm.credentialsPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to remove credentials file: %w", err)
|
||||
}
|
||||
@@ -312,6 +310,104 @@ func (cm *CredentialManager) RemoveOpenAICredentials() error {
|
||||
return cm.SaveCredentials(store)
|
||||
}
|
||||
|
||||
// GetCopilotCredentials retrieves stored GitHub Copilot credentials.
|
||||
func (cm *CredentialManager) GetCopilotCredentials() (*CopilotCredentials, error) {
|
||||
store, err := cm.LoadCredentials()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return store.Copilot, nil
|
||||
}
|
||||
|
||||
// RemoveCopilotCredentials removes stored GitHub Copilot credentials.
|
||||
func (cm *CredentialManager) RemoveCopilotCredentials() error {
|
||||
store, err := cm.LoadCredentials()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
store.Copilot = nil
|
||||
|
||||
if store.Anthropic == nil && store.OpenAI == nil && store.Copilot == nil {
|
||||
if err := os.Remove(cm.credentialsPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to remove credentials file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return cm.SaveCredentials(store)
|
||||
}
|
||||
|
||||
// HasCopilotCredentials checks if valid GitHub Copilot credentials are stored.
|
||||
func (cm *CredentialManager) HasCopilotCredentials() (bool, error) {
|
||||
creds, err := cm.GetCopilotCredentials()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if creds == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return creds.Type == "oauth" && creds.GitHubToken != "", nil
|
||||
}
|
||||
|
||||
// SetCopilotOAuthCredentials stores GitHub Copilot OAuth credentials.
|
||||
func (cm *CredentialManager) SetCopilotOAuthCredentials(creds *CopilotCredentials) error {
|
||||
store, err := cm.LoadCredentials()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
store.Copilot = creds
|
||||
return cm.SaveCredentials(store)
|
||||
}
|
||||
|
||||
// GetValidCopilotAccessToken returns a fresh Copilot API token, renewing it
|
||||
// with the stored GitHub OAuth token when needed.
|
||||
func (cm *CredentialManager) GetValidCopilotAccessToken() (string, error) {
|
||||
return cm.GetValidCopilotAccessTokenContext(context.Background())
|
||||
}
|
||||
|
||||
// GetValidCopilotAccessTokenContext returns a fresh Copilot API token, renewing
|
||||
// it with the stored GitHub OAuth token when needed.
|
||||
func (cm *CredentialManager) GetValidCopilotAccessTokenContext(ctx context.Context) (string, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
creds, err := cm.GetCopilotCredentials()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if creds == nil {
|
||||
return "", fmt.Errorf("no Copilot credentials found")
|
||||
}
|
||||
if creds.Type != "oauth" {
|
||||
return "", fmt.Errorf("unknown credential type: %s", creds.Type)
|
||||
}
|
||||
if creds.GitHubToken == "" {
|
||||
return "", fmt.Errorf("GitHub OAuth token missing from Copilot credentials")
|
||||
}
|
||||
|
||||
if creds.CopilotAccessToken == "" || creds.NeedsRefresh() {
|
||||
client := NewCopilotOAuthClient()
|
||||
newCreds, err := client.RefreshCopilotToken(ctx, creds.GitHubToken)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to refresh Copilot token: %w", err)
|
||||
}
|
||||
newCreds.CreatedAt = creds.CreatedAt
|
||||
|
||||
if err := cm.SetCopilotOAuthCredentials(newCreds); err != nil {
|
||||
return "", fmt.Errorf("failed to save refreshed Copilot token: %w", err)
|
||||
}
|
||||
|
||||
return newCreds.CopilotAccessToken, nil
|
||||
}
|
||||
|
||||
return creds.CopilotAccessToken, nil
|
||||
}
|
||||
|
||||
// HasOpenAICredentials checks if valid OpenAI credentials are stored.
|
||||
// Returns true if either a non-empty OAuth access token or API key is present,
|
||||
// false otherwise. Returns an error if credentials cannot be loaded.
|
||||
@@ -417,24 +513,18 @@ func validateAnthropicAPIKey(apiKey string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateOpenAIAPIKey validates the format of an OpenAI API key
|
||||
func validateOpenAIAPIKey(apiKey string) error {
|
||||
apiKey = strings.TrimSpace(apiKey)
|
||||
// CredentialSourceOAuth is the source description returned by
|
||||
// GetAnthropicAPIKey when the key resolves to stored OAuth credentials.
|
||||
// Consumers should compare against this constant (or use IsAnthropicOAuth)
|
||||
// rather than matching the string literal.
|
||||
const CredentialSourceOAuth = "stored OAuth credentials"
|
||||
|
||||
if apiKey == "" {
|
||||
return fmt.Errorf("API key cannot be empty")
|
||||
}
|
||||
|
||||
// OpenAI API keys typically start with "sk-" and are quite long
|
||||
if !strings.HasPrefix(apiKey, "sk-") {
|
||||
return fmt.Errorf("invalid OpenAI API key format (should start with 'sk-')")
|
||||
}
|
||||
|
||||
if len(apiKey) < 20 {
|
||||
return fmt.Errorf("API key appears to be too short")
|
||||
}
|
||||
|
||||
return nil
|
||||
// IsAnthropicOAuth reports whether the active Anthropic credential resolves
|
||||
// to a stored OAuth token (in which case the user is not billed per-token).
|
||||
// flagValue is the --provider-api-key flag value (may be empty).
|
||||
func IsAnthropicOAuth(flagValue string) bool {
|
||||
_, source, err := GetAnthropicAPIKey(flagValue)
|
||||
return err == nil && source == CredentialSourceOAuth
|
||||
}
|
||||
|
||||
// GetAnthropicAPIKey retrieves an Anthropic API key from multiple sources in priority order:
|
||||
@@ -459,7 +549,7 @@ func GetAnthropicAPIKey(flagValue string) (string, string, error) {
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to get valid OAuth token: %w", err)
|
||||
}
|
||||
return token, "stored OAuth credentials", nil
|
||||
return token, CredentialSourceOAuth, nil
|
||||
} else if creds.Type == "api_key" && creds.APIKey != "" {
|
||||
return creds.APIKey, "stored API key", nil
|
||||
}
|
||||
@@ -471,5 +561,13 @@ func GetAnthropicAPIKey(flagValue string) (string, string, error) {
|
||||
return envKey, "ANTHROPIC_API_KEY environment variable", nil
|
||||
}
|
||||
|
||||
// Check if OpenAI credentials exist to provide a helpful suggestion
|
||||
if cm != nil {
|
||||
hasOpenAI, _ := cm.HasOpenAICredentials()
|
||||
if hasOpenAI {
|
||||
return "", "", fmt.Errorf("no Anthropic API key found. Use 'kit auth login anthropic', set ANTHROPIC_API_KEY environment variable, or use --provider-api-key flag\n\nNote: OpenAI credentials were detected. To use OpenAI, run with --model openai/gpt-5.4 or set it as default:\n kit auth login openai --set-default")
|
||||
}
|
||||
}
|
||||
|
||||
return "", "", fmt.Errorf("no Anthropic API key found. Use 'kit auth login anthropic', set ANTHROPIC_API_KEY environment variable, or use --provider-api-key flag")
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCredentialManager(t *testing.T) {
|
||||
@@ -215,6 +216,7 @@ func TestCredentialStorePersistence(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
credentialsPath := filepath.Join(tempDir, "credentials.json")
|
||||
@@ -252,3 +254,98 @@ func TestCredentialStorePersistence(t *testing.T) {
|
||||
t.Errorf("Expected file permissions 0600, got %v", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopilotCredentials(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "kit-auth-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
cm := &CredentialManager{
|
||||
credentialsPath: filepath.Join(tempDir, "credentials.json"),
|
||||
}
|
||||
|
||||
creds := &CopilotCredentials{
|
||||
Type: "oauth",
|
||||
GitHubToken: "github-token",
|
||||
CopilotAccessToken: "copilot-token",
|
||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := cm.SetCopilotOAuthCredentials(creds); err != nil {
|
||||
t.Fatalf("SetCopilotOAuthCredentials failed: %v", err)
|
||||
}
|
||||
|
||||
hasAuth, err := cm.HasCopilotCredentials()
|
||||
if err != nil {
|
||||
t.Fatalf("HasCopilotCredentials failed: %v", err)
|
||||
}
|
||||
if !hasAuth {
|
||||
t.Fatal("Expected Copilot credentials")
|
||||
}
|
||||
|
||||
token, err := cm.GetValidCopilotAccessToken()
|
||||
if err != nil {
|
||||
t.Fatalf("GetValidCopilotAccessToken failed: %v", err)
|
||||
}
|
||||
if token != creds.CopilotAccessToken {
|
||||
t.Fatalf("Expected Copilot token %q, got %q", creds.CopilotAccessToken, token)
|
||||
}
|
||||
|
||||
if err := cm.RemoveCopilotCredentials(); err != nil {
|
||||
t.Fatalf("RemoveCopilotCredentials failed: %v", err)
|
||||
}
|
||||
hasAuth, err = cm.HasCopilotCredentials()
|
||||
if err != nil {
|
||||
t.Fatalf("HasCopilotCredentials after removal failed: %v", err)
|
||||
}
|
||||
if hasAuth {
|
||||
t.Fatal("Expected no Copilot credentials after removal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveCredentialsPreservesOtherProviders(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "kit-auth-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
cm := &CredentialManager{
|
||||
credentialsPath: filepath.Join(tempDir, "credentials.json"),
|
||||
}
|
||||
|
||||
if err := cm.SetOpenAIOAuthCredentials(&OpenAICredentials{
|
||||
Type: "oauth",
|
||||
AccessToken: "openai-token",
|
||||
RefreshToken: "refresh-token",
|
||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
||||
AccountID: "account",
|
||||
CreatedAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatalf("SetOpenAIOAuthCredentials failed: %v", err)
|
||||
}
|
||||
if err := cm.SetCopilotOAuthCredentials(&CopilotCredentials{
|
||||
Type: "oauth",
|
||||
GitHubToken: "github-token",
|
||||
CopilotAccessToken: "copilot-token",
|
||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
||||
CreatedAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatalf("SetCopilotOAuthCredentials failed: %v", err)
|
||||
}
|
||||
|
||||
if err := cm.RemoveCopilotCredentials(); err != nil {
|
||||
t.Fatalf("RemoveCopilotCredentials failed: %v", err)
|
||||
}
|
||||
|
||||
hasOpenAI, err := cm.HasOpenAICredentials()
|
||||
if err != nil {
|
||||
t.Fatalf("HasOpenAICredentials failed: %v", err)
|
||||
}
|
||||
if !hasOpenAI {
|
||||
t.Fatal("Expected OpenAI credentials to remain after removing Copilot credentials")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -211,6 +212,262 @@ type OpenAIOAuthClient struct {
|
||||
Scopes string
|
||||
}
|
||||
|
||||
// CopilotOAuthClient handles GitHub device-flow OAuth and exchanges the
|
||||
// GitHub token for a short-lived GitHub Copilot API token.
|
||||
//
|
||||
// The GitHub token comes from GitHub's OAuth device flow. It is then presented
|
||||
// to GitHub's internal Copilot token endpoint, which returns the bearer token
|
||||
// used by api.githubcopilot.com.
|
||||
type CopilotOAuthClient struct {
|
||||
ClientID string
|
||||
DeviceURL string
|
||||
TokenURL string
|
||||
CopilotURL string
|
||||
Scopes string
|
||||
PollTimeout time.Duration
|
||||
ClientTimeout time.Duration
|
||||
}
|
||||
|
||||
// CopilotDeviceCode contains data returned by GitHub's device-code endpoint.
|
||||
type CopilotDeviceCode struct {
|
||||
DeviceCode string `json:"device_code"`
|
||||
UserCode string `json:"user_code"`
|
||||
VerificationURI string `json:"verification_uri"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Interval int `json:"interval"`
|
||||
}
|
||||
|
||||
// NewCopilotOAuthClient creates a GitHub Copilot OAuth client.
|
||||
func NewCopilotOAuthClient() *CopilotOAuthClient {
|
||||
return &CopilotOAuthClient{
|
||||
ClientID: "Iv1.b507a08c87ecfe98",
|
||||
DeviceURL: "https://github.com/login/device/code",
|
||||
TokenURL: "https://github.com/login/oauth/access_token",
|
||||
CopilotURL: "https://api.github.com/copilot_internal/v2/token",
|
||||
Scopes: "read:user",
|
||||
PollTimeout: 15 * time.Minute,
|
||||
ClientTimeout: 30 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// StartDeviceFlow requests a GitHub device code for browser login.
|
||||
//
|
||||
// The returned user code and verification URI are displayed by loginCopilot.
|
||||
// GitHub's response may omit interval, so this method normalizes it to the
|
||||
// documented five-second default.
|
||||
func (c *CopilotOAuthClient) StartDeviceFlow(ctx context.Context) (*CopilotDeviceCode, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
data := url.Values{
|
||||
"client_id": {c.ClientID},
|
||||
"scope": {c.Scopes},
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", c.DeviceURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create device-code request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := (&http.Client{Timeout: c.ClientTimeout}).Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to request device code: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("device-code request failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var code CopilotDeviceCode
|
||||
if err := json.NewDecoder(resp.Body).Decode(&code); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode device-code response: %w", err)
|
||||
}
|
||||
if code.DeviceCode == "" || code.UserCode == "" || code.VerificationURI == "" {
|
||||
return nil, fmt.Errorf("device-code response missing required fields")
|
||||
}
|
||||
if code.Interval <= 0 {
|
||||
code.Interval = 5
|
||||
}
|
||||
return &code, nil
|
||||
}
|
||||
|
||||
// PollDeviceToken waits until the user authorizes the device code and returns
|
||||
// the resulting GitHub OAuth token.
|
||||
//
|
||||
// It follows GitHub's device-flow polling contract: authorization_pending keeps
|
||||
// polling, slow_down increases the interval, and polling stops at the earlier of
|
||||
// the client timeout or the device-code expiry.
|
||||
func (c *CopilotOAuthClient) PollDeviceToken(ctx context.Context, deviceCode *CopilotDeviceCode) (string, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
if deviceCode == nil || deviceCode.DeviceCode == "" {
|
||||
return "", fmt.Errorf("device code missing")
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(c.PollTimeout)
|
||||
if deviceCode.ExpiresIn > 0 {
|
||||
expiresAt := time.Now().Add(time.Duration(deviceCode.ExpiresIn) * time.Second)
|
||||
if expiresAt.Before(deadline) {
|
||||
deadline = expiresAt
|
||||
}
|
||||
}
|
||||
|
||||
interval := time.Duration(deviceCode.Interval) * time.Second
|
||||
if interval <= 0 {
|
||||
interval = 5 * time.Second
|
||||
}
|
||||
|
||||
for time.Now().Before(deadline) {
|
||||
wait := interval
|
||||
if remaining := time.Until(deadline); remaining < wait {
|
||||
wait = remaining
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-time.After(wait):
|
||||
}
|
||||
|
||||
data := url.Values{
|
||||
"client_id": {c.ClientID},
|
||||
"device_code": {deviceCode.DeviceCode},
|
||||
"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", c.TokenURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create device-token request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := (&http.Client{Timeout: c.ClientTimeout}).Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to poll device token: %w", err)
|
||||
}
|
||||
|
||||
var tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
Error string `json:"error"`
|
||||
Description string `json:"error_description"`
|
||||
}
|
||||
decodeErr := json.NewDecoder(resp.Body).Decode(&tokenResp)
|
||||
_ = resp.Body.Close()
|
||||
if decodeErr != nil {
|
||||
return "", fmt.Errorf("failed to decode device-token response: %w", decodeErr)
|
||||
}
|
||||
|
||||
if tokenResp.AccessToken != "" {
|
||||
return tokenResp.AccessToken, nil
|
||||
}
|
||||
|
||||
switch tokenResp.Error {
|
||||
case "authorization_pending":
|
||||
continue
|
||||
case "slow_down":
|
||||
interval += 5 * time.Second
|
||||
continue
|
||||
case "expired_token":
|
||||
return "", fmt.Errorf("device code expired; restart login")
|
||||
case "access_denied":
|
||||
return "", fmt.Errorf("github login denied")
|
||||
case "":
|
||||
return "", fmt.Errorf("device-token request failed with status %d", resp.StatusCode)
|
||||
default:
|
||||
if tokenResp.Description != "" {
|
||||
return "", fmt.Errorf("device-token request failed: %s: %s", tokenResp.Error, tokenResp.Description)
|
||||
}
|
||||
return "", fmt.Errorf("device-token request failed: %s", tokenResp.Error)
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("timed out waiting for github device authorization")
|
||||
}
|
||||
|
||||
// ExchangeGitHubToken converts a GitHub OAuth token into a Copilot API token.
|
||||
// It is a semantic wrapper over RefreshCopilotToken used by the login flow.
|
||||
func (c *CopilotOAuthClient) ExchangeGitHubToken(ctx context.Context, githubToken string) (*CopilotCredentials, error) {
|
||||
return c.RefreshCopilotToken(ctx, githubToken)
|
||||
}
|
||||
|
||||
// RefreshCopilotToken obtains a fresh short-lived Copilot token from GitHub.
|
||||
//
|
||||
// GitHub may return expires_at as either a Unix timestamp or RFC3339 string.
|
||||
// parseCopilotExpiry handles both forms and falls back to a conservative
|
||||
// 20-minute lifetime when the field is absent or unrecognized.
|
||||
func (c *CopilotOAuthClient) RefreshCopilotToken(ctx context.Context, githubToken string) (*CopilotCredentials, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", c.CopilotURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create copilot token request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "token "+githubToken)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "kit")
|
||||
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
|
||||
|
||||
resp, err := (&http.Client{Timeout: c.ClientTimeout}).Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to request copilot token: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("copilot token request failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var tokenResp struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt any `json:"expires_at"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode copilot token response: %w", err)
|
||||
}
|
||||
if tokenResp.Token == "" {
|
||||
return nil, fmt.Errorf("copilot token response missing token")
|
||||
}
|
||||
|
||||
expiresAt := parseCopilotExpiry(tokenResp.ExpiresAt)
|
||||
if expiresAt == 0 {
|
||||
expiresAt = time.Now().Add(20 * time.Minute).Unix()
|
||||
}
|
||||
|
||||
return &CopilotCredentials{
|
||||
Type: "oauth",
|
||||
GitHubToken: githubToken,
|
||||
CopilotAccessToken: tokenResp.Token,
|
||||
ExpiresAt: expiresAt,
|
||||
CreatedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseCopilotExpiry normalizes GitHub's expires_at variants to a Unix second.
|
||||
func parseCopilotExpiry(value any) int64 {
|
||||
switch v := value.(type) {
|
||||
case float64:
|
||||
return int64(v)
|
||||
case string:
|
||||
if parsed, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
return parsed
|
||||
}
|
||||
if parsed, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
return parsed.Unix()
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// NewOpenAIOAuthClient creates a new OAuth client configured for OpenAI Codex OAuth.
|
||||
// This uses the public client ID for CLI applications with PKCE for security.
|
||||
func NewOpenAIOAuthClient() *OpenAIOAuthClient {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCopilotStartDeviceFlow(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatalf("ParseForm failed: %v", err)
|
||||
}
|
||||
if r.Form.Get("client_id") != "client-id" {
|
||||
t.Fatalf("expected client id, got %q", r.Form.Get("client_id"))
|
||||
}
|
||||
if r.Form.Get("scope") != "read:user" {
|
||||
t.Fatalf("expected scope, got %q", r.Form.Get("scope"))
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"device_code": "device-code",
|
||||
"user_code": "USER-CODE",
|
||||
"verification_uri": "https://github.com/login/device",
|
||||
"expires_in": 600,
|
||||
"interval": 1,
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewCopilotOAuthClient()
|
||||
client.ClientID = "client-id"
|
||||
client.DeviceURL = server.URL
|
||||
|
||||
code, err := client.StartDeviceFlow(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("StartDeviceFlow failed: %v", err)
|
||||
}
|
||||
if code.DeviceCode != "device-code" || code.UserCode != "USER-CODE" || code.Interval != 1 {
|
||||
t.Fatalf("unexpected device code: %#v", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopilotPollDeviceToken(t *testing.T) {
|
||||
polls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
polls++
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatalf("ParseForm failed: %v", err)
|
||||
}
|
||||
if r.Form.Get("grant_type") != "urn:ietf:params:oauth:grant-type:device_code" {
|
||||
t.Fatalf("unexpected grant type: %q", r.Form.Get("grant_type"))
|
||||
}
|
||||
if polls == 1 {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"error": "authorization_pending"})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "github-token"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewCopilotOAuthClient()
|
||||
client.ClientID = "client-id"
|
||||
client.TokenURL = server.URL
|
||||
client.PollTimeout = 5 * time.Second
|
||||
client.ClientTimeout = time.Second
|
||||
|
||||
token, err := client.PollDeviceToken(context.Background(), &CopilotDeviceCode{
|
||||
DeviceCode: "device-code",
|
||||
ExpiresIn: 10,
|
||||
Interval: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PollDeviceToken failed: %v", err)
|
||||
}
|
||||
if token != "github-token" {
|
||||
t.Fatalf("expected github-token, got %q", token)
|
||||
}
|
||||
if polls != 2 {
|
||||
t.Fatalf("expected 2 polls, got %d", polls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopilotRefreshToken(t *testing.T) {
|
||||
expiresAt := time.Now().Add(time.Hour).Unix()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Fatalf("expected GET, got %s", r.Method)
|
||||
}
|
||||
if r.Header.Get("Authorization") != "token github-token" {
|
||||
t.Fatalf("unexpected authorization header: %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
if r.Header.Get("User-Agent") != "kit" {
|
||||
t.Fatalf("unexpected user agent: %q", r.Header.Get("User-Agent"))
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"token": "copilot-token",
|
||||
"expires_at": expiresAt,
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewCopilotOAuthClient()
|
||||
client.CopilotURL = server.URL
|
||||
|
||||
creds, err := client.RefreshCopilotToken(context.Background(), "github-token")
|
||||
if err != nil {
|
||||
t.Fatalf("RefreshCopilotToken failed: %v", err)
|
||||
}
|
||||
if creds.GitHubToken != "github-token" || creds.CopilotAccessToken != "copilot-token" {
|
||||
t.Fatalf("unexpected credentials: %#v", creds)
|
||||
}
|
||||
if creds.ExpiresAt != expiresAt {
|
||||
t.Fatalf("expected expires_at %d, got %d", expiresAt, creds.ExpiresAt)
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,37 @@ type MCPServerConfig struct {
|
||||
OAuthClientSecret string `json:"oauthClientSecret,omitempty" yaml:"oauthClientSecret,omitempty"`
|
||||
OAuthScopes []string `json:"oauthScopes,omitempty" yaml:"oauthScopes,omitempty"`
|
||||
|
||||
// NoOAuth disables OAuth transport configuration for this server, even
|
||||
// when the connection pool has an auth handler. Use this for public MCP
|
||||
// servers (e.g. PubMed) that don't require authentication. Without this
|
||||
// flag, the pool would attach OAuth transport to every remote server,
|
||||
// causing proactive dynamic-client-registration attempts that fail on
|
||||
// servers that don't support it.
|
||||
NoOAuth bool `json:"noOAuth,omitempty" yaml:"noOAuth,omitempty"`
|
||||
|
||||
// TasksMode controls when this server's tools/call requests are augmented
|
||||
// with MCP task metadata (turning a synchronous call into an asynchronous,
|
||||
// pollable job — see https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks).
|
||||
//
|
||||
// Valid values:
|
||||
// - "" or "auto": (default) augment requests with task metadata only
|
||||
// when the server advertises tasks/toolCalls capability during initialize.
|
||||
// - "never": never augment — every tool call is synchronous, regardless
|
||||
// of server capability.
|
||||
// - "always": always augment, even when the server didn't advertise
|
||||
// task support. The server may still respond synchronously; this just
|
||||
// opts in unconditionally on the client side.
|
||||
//
|
||||
// In all modes, when the server returns a CreateTaskResult the client polls
|
||||
// tasks/get / tasks/result until the task reaches a terminal state.
|
||||
TasksMode string `json:"tasksMode,omitempty" yaml:"tasksMode,omitempty"`
|
||||
|
||||
// InProcessServer holds a live *server.MCPServer for in-process transport.
|
||||
// When set (and Type is "inprocess"), the connection pool creates an
|
||||
// in-process client instead of spawning a subprocess or making HTTP calls.
|
||||
// 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"`
|
||||
@@ -53,6 +84,8 @@ func (s *MCPServerConfig) UnmarshalJSON(data []byte) error {
|
||||
OAuthClientID string `json:"oauthClientId,omitempty" yaml:"oauthClientId,omitempty"`
|
||||
OAuthClientSecret string `json:"oauthClientSecret,omitempty" yaml:"oauthClientSecret,omitempty"`
|
||||
OAuthScopes []string `json:"oauthScopes,omitempty" yaml:"oauthScopes,omitempty"`
|
||||
NoOAuth bool `json:"noOAuth,omitempty" yaml:"noOAuth,omitempty"`
|
||||
TasksMode string `json:"tasksMode,omitempty" yaml:"tasksMode,omitempty"`
|
||||
}
|
||||
|
||||
// Also try legacy format
|
||||
@@ -65,6 +98,7 @@ func (s *MCPServerConfig) UnmarshalJSON(data []byte) error {
|
||||
Headers []string `json:"headers,omitempty"`
|
||||
AllowedTools []string `json:"allowedTools,omitempty" yaml:"allowedTools,omitempty"`
|
||||
ExcludedTools []string `json:"excludedTools,omitempty" yaml:"excludedTools,omitempty"`
|
||||
TasksMode string `json:"tasksMode,omitempty" yaml:"tasksMode,omitempty"`
|
||||
}
|
||||
|
||||
// Try new format first
|
||||
@@ -80,6 +114,8 @@ func (s *MCPServerConfig) UnmarshalJSON(data []byte) error {
|
||||
s.OAuthClientID = newConfig.OAuthClientID
|
||||
s.OAuthClientSecret = newConfig.OAuthClientSecret
|
||||
s.OAuthScopes = newConfig.OAuthScopes
|
||||
s.NoOAuth = newConfig.NoOAuth
|
||||
s.TasksMode = newConfig.TasksMode
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -100,6 +136,7 @@ func (s *MCPServerConfig) UnmarshalJSON(data []byte) error {
|
||||
s.Headers = legacyConfig.Headers
|
||||
s.AllowedTools = legacyConfig.AllowedTools
|
||||
s.ExcludedTools = legacyConfig.ExcludedTools
|
||||
s.TasksMode = legacyConfig.TasksMode
|
||||
|
||||
// Infer type from legacy format for better compatibility
|
||||
// Only set Type when it doesn't change existing transport behavior
|
||||
@@ -277,11 +314,18 @@ 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"
|
||||
@@ -301,6 +345,17 @@ func (c *Config) Validate() error {
|
||||
return fmt.Errorf("server %s: allowedTools and excludedTools are mutually exclusive", serverName)
|
||||
}
|
||||
|
||||
// Reject unknown tasksMode values up front so a typo (e.g. "alwasy")
|
||||
// fails loud here instead of being silently downgraded to "auto" by
|
||||
// the runtime parser. Comparison is case-insensitive to match
|
||||
// tools.ParseTaskMode.
|
||||
switch strings.ToLower(strings.TrimSpace(serverConfig.TasksMode)) {
|
||||
case "", "auto", "never", "always":
|
||||
// ok
|
||||
default:
|
||||
return fmt.Errorf("server %s: invalid tasksMode %q (expected one of: auto, never, always)", serverName, serverConfig.TasksMode)
|
||||
}
|
||||
|
||||
transport := serverConfig.GetTransportType()
|
||||
switch transport {
|
||||
case "stdio":
|
||||
@@ -312,8 +367,12 @@ 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", serverName, transport)
|
||||
return fmt.Errorf("server %s: unsupported transport type '%s'. Supported types: stdio, sse, streamable, inprocess", serverName, transport)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -434,6 +493,12 @@ mcpServers:
|
||||
# maxTokens: 16384
|
||||
# systemPrompt: "You are a deep reasoning assistant." # or a file path
|
||||
|
||||
# Skills configuration (all optional)
|
||||
# no-skills: false # Set to true to disable all skill loading
|
||||
# skill: # Explicit skill files/dirs (disables auto-discovery)
|
||||
# - "/path/to/skill.md"
|
||||
# skills-dir: "/path/to/skills" # Override project-local directory for auto-discovery
|
||||
|
||||
# API Configuration (can also use environment variables)
|
||||
# provider-api-key: "your-api-key" # API key for OpenAI, Anthropic, or Google
|
||||
# provider-url: "https://api.openai.com/v1" # Base URL for OpenAI, Anthropic, or Ollama
|
||||
|
||||
@@ -205,6 +205,9 @@ func TestEnsureConfigExists(t *testing.T) {
|
||||
"type: \"local\"",
|
||||
"type: \"remote\"",
|
||||
"Core tools",
|
||||
"# Skills configuration",
|
||||
"no-skills:",
|
||||
"skills-dir:",
|
||||
}
|
||||
|
||||
for _, expected := range expectedSections {
|
||||
@@ -627,3 +630,92 @@ func TestMCPServerConfig_OAuthFields_Omitted(t *testing.T) {
|
||||
t.Errorf("Expected empty OAuthScopes, got %v", cfg.OAuthScopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPServerConfig_TasksMode_NewFormat(t *testing.T) {
|
||||
jsonData := `{
|
||||
"type": "remote",
|
||||
"url": "https://my-mcp-server.com",
|
||||
"tasksMode": "always"
|
||||
}`
|
||||
var cfg MCPServerConfig
|
||||
if err := json.Unmarshal([]byte(jsonData), &cfg); err != nil {
|
||||
t.Fatalf("Failed to unmarshal: %v", err)
|
||||
}
|
||||
if cfg.TasksMode != "always" {
|
||||
t.Errorf("expected TasksMode 'always', got %q", cfg.TasksMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPServerConfig_TasksMode_LegacyFormat(t *testing.T) {
|
||||
// tasksMode also recognised in the legacy unmarshal path so users on
|
||||
// the older command/args shape can opt in without migrating.
|
||||
jsonData := `{
|
||||
"command": "npx",
|
||||
"args": ["@modelcontextprotocol/server-filesystem", "/path"],
|
||||
"tasksMode": "never"
|
||||
}`
|
||||
var cfg MCPServerConfig
|
||||
if err := json.Unmarshal([]byte(jsonData), &cfg); err != nil {
|
||||
t.Fatalf("Failed to unmarshal: %v", err)
|
||||
}
|
||||
if cfg.TasksMode != "never" {
|
||||
t.Errorf("expected TasksMode 'never', got %q", cfg.TasksMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPServerConfig_TasksMode_DefaultEmpty(t *testing.T) {
|
||||
// When tasksMode is not set the field stays empty, which downstream
|
||||
// resolves to "auto" via tools.ParseTaskMode.
|
||||
jsonData := `{"type":"remote","url":"https://x.example"}`
|
||||
var cfg MCPServerConfig
|
||||
if err := json.Unmarshal([]byte(jsonData), &cfg); err != nil {
|
||||
t.Fatalf("Failed to unmarshal: %v", err)
|
||||
}
|
||||
if cfg.TasksMode != "" {
|
||||
t.Errorf("expected default TasksMode to be empty, got %q", cfg.TasksMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_Validate_TasksMode(t *testing.T) {
|
||||
t.Run("empty is valid", func(t *testing.T) {
|
||||
cfg := &Config{
|
||||
MCPServers: map[string]MCPServerConfig{
|
||||
"a": {Type: "remote", URL: "https://x.example"},
|
||||
},
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Errorf("empty TasksMode should validate, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("known values are valid", func(t *testing.T) {
|
||||
for _, mode := range []string{"auto", "never", "always", "AUTO", " always "} {
|
||||
cfg := &Config{
|
||||
MCPServers: map[string]MCPServerConfig{
|
||||
"a": {Type: "remote", URL: "https://x.example", TasksMode: mode},
|
||||
},
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Errorf("TasksMode=%q should validate, got %v", mode, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("typo is rejected with a clear error", func(t *testing.T) {
|
||||
cfg := &Config{
|
||||
MCPServers: map[string]MCPServerConfig{
|
||||
"buildbot": {Type: "remote", URL: "https://x.example", TasksMode: "alwasy"},
|
||||
},
|
||||
}
|
||||
err := cfg.Validate()
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for invalid TasksMode")
|
||||
}
|
||||
// Error must mention the server name AND the bad value so the
|
||||
// user knows where to look.
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "buildbot") || !strings.Contains(msg, `"alwasy"`) {
|
||||
t.Errorf("error %q should mention both server name and bad value", msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,32 +7,48 @@ import (
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// LoadAndValidateConfig loads configuration from viper, fixes environment variable
|
||||
// casing issues, and validates the configuration. Returns an error if loading or
|
||||
// validation fails.
|
||||
// LoadAndValidateConfig loads configuration from the process-global viper
|
||||
// store, fixes environment variable casing issues, and validates the
|
||||
// configuration. Returns an error if loading or validation fails.
|
||||
//
|
||||
// This is a convenience wrapper around [LoadAndValidateConfigFrom] using the
|
||||
// shared global store; it is retained for the CLI and other callers that rely
|
||||
// on viper's process-global state.
|
||||
func LoadAndValidateConfig() (*Config, error) {
|
||||
return LoadAndValidateConfigFrom(viper.GetViper())
|
||||
}
|
||||
|
||||
// LoadAndValidateConfigFrom loads configuration from the supplied per-instance
|
||||
// store, fixes environment variable casing issues, and validates the
|
||||
// configuration. When v is nil, the process-global store is used. Threading an
|
||||
// explicit store lets each Kit instance own an isolated configuration without
|
||||
// clobbering other instances in the same process.
|
||||
func LoadAndValidateConfigFrom(v *viper.Viper) (*Config, error) {
|
||||
if v == nil {
|
||||
v = viper.GetViper()
|
||||
}
|
||||
config := &Config{
|
||||
MCPServers: make(map[string]MCPServerConfig),
|
||||
}
|
||||
if err := viper.Unmarshal(config); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal config: %v", err)
|
||||
if err := v.Unmarshal(config); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
|
||||
}
|
||||
|
||||
// Fix environment variable case sensitivity issue
|
||||
// Viper lowercases all keys, but we need to preserve the original case for environment variables
|
||||
fixEnvironmentCase(config)
|
||||
fixEnvironmentCase(v, config)
|
||||
|
||||
if err := config.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid config: %v", err)
|
||||
return nil, fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// fixEnvironmentCase fixes the case of environment variable keys that were lowercased by Viper
|
||||
func fixEnvironmentCase(config *Config) {
|
||||
func fixEnvironmentCase(v *viper.Viper, config *Config) {
|
||||
// Get the raw config data from viper
|
||||
rawConfig := viper.AllSettings()
|
||||
rawConfig := v.AllSettings()
|
||||
|
||||
// Check if we have mcpServers in the raw config
|
||||
if mcpServersRaw, ok := rawConfig["mcpservers"]; ok {
|
||||
|
||||
@@ -56,9 +56,3 @@ func (e *EnvSubstituter) SubstituteEnvVars(content string) (string, error) {
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// HasEnvVars checks if content contains environment variable patterns (${env://...}).
|
||||
// This is useful for determining if substitution is needed before processing.
|
||||
func HasEnvVars(content string) bool {
|
||||
return envVarPattern.MatchString(content)
|
||||
}
|
||||
|
||||
@@ -187,41 +187,3 @@ func TestEnvSubstituter_SubstituteEnvVars(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasEnvVars(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "has env vars",
|
||||
content: `{"token": "${env://GITHUB_TOKEN}"}`,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "has env vars with default",
|
||||
content: `{"debug": "${env://DEBUG:-false}"}`,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "no env vars",
|
||||
content: `{"name": "${username}", "normal": "value"}`,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "empty content",
|
||||
content: "",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := HasEnvVars(tt.content)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+201
-52
@@ -19,10 +19,18 @@ 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"
|
||||
const (
|
||||
toolOutputCallbackKey contextKey = "toolOutputCallback"
|
||||
sudoPasswordKey contextKey = "sudoPassword"
|
||||
passwordPromptKey contextKey = "passwordPrompt"
|
||||
)
|
||||
|
||||
// ContextWithToolOutputCallback returns a new context with the tool output callback set.
|
||||
func ContextWithToolOutputCallback(ctx context.Context, callback ToolOutputCallback) context.Context {
|
||||
@@ -37,6 +45,28 @@ 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
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -73,6 +103,57 @@ 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
|
||||
}
|
||||
|
||||
func executeBash(ctx context.Context, call fantasy.ToolCall, workDir string) (fantasy.ToolResponse, error) {
|
||||
var args bashArgs
|
||||
if err := parseArgs(call.Input, &args); err != nil {
|
||||
@@ -97,7 +178,47 @@ func executeBash(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
|
||||
cmdCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(cmdCtx, "bash", "-c", args.Command)
|
||||
// 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)
|
||||
if workDir != "" {
|
||||
cmd.Dir = workDir
|
||||
}
|
||||
@@ -115,44 +236,94 @@ 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)
|
||||
return executeBashStreaming(cmdCtx, call, cmd, outputCallback, sudoPassword)
|
||||
}
|
||||
|
||||
// Non-streaming mode: collect all output at once (original behavior)
|
||||
return executeBashBuffered(cmdCtx, call, cmd)
|
||||
return executeBashBuffered(cmdCtx, call, cmd, sudoPassword)
|
||||
}
|
||||
|
||||
// setupBashPipes opens stdout/stderr pipes (plus an optional sudo stdin),
|
||||
// starts the command, and asynchronously writes the sudo password if any.
|
||||
// Returns the readers ready for the caller to consume. If setup fails,
|
||||
// errResp is non-nil and the readers must not be used; the caller should
|
||||
// return the response directly.
|
||||
func setupBashPipes(cmd *exec.Cmd, sudoPassword string) (stdout, stderr io.Reader, errResp *fantasy.ToolResponse) {
|
||||
stdoutPipe, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
r := fantasy.NewTextErrorResponse("failed to create stdout pipe")
|
||||
return nil, nil, &r
|
||||
}
|
||||
stderrPipe, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
r := fantasy.NewTextErrorResponse("failed to create stderr pipe")
|
||||
return nil, nil, &r
|
||||
}
|
||||
|
||||
var stdinPipe io.WriteCloser
|
||||
if sudoPassword != "" {
|
||||
stdinPipe, err = cmd.StdinPipe()
|
||||
if err != nil {
|
||||
r := fantasy.NewTextErrorResponse("failed to create stdin pipe")
|
||||
return nil, nil, &r
|
||||
}
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
r := fantasy.NewTextErrorResponse(fmt.Sprintf("failed to start command: %v", err))
|
||||
return nil, nil, &r
|
||||
}
|
||||
|
||||
if sudoPassword != "" && stdinPipe != nil {
|
||||
go func() {
|
||||
defer func() { _ = stdinPipe.Close() }()
|
||||
_, _ = io.WriteString(stdinPipe, sudoPassword+"\n")
|
||||
}()
|
||||
}
|
||||
|
||||
return stdoutPipe, stderrPipe, nil
|
||||
}
|
||||
|
||||
// interpretBashExit decodes cmd.Wait()'s error into an exit code, mapping
|
||||
// context-deadline-exceeded to a friendly "command timed out" response.
|
||||
// errResp is non-nil only when the caller should short-circuit and return
|
||||
// it directly (e.g. timeout).
|
||||
func interpretBashExit(waitErr error, cmdCtx context.Context) (exitCode int, errResp *fantasy.ToolResponse) {
|
||||
if waitErr == nil {
|
||||
return 0, nil
|
||||
}
|
||||
if exitErr, ok := waitErr.(*exec.ExitError); ok {
|
||||
return exitErr.ExitCode(), nil
|
||||
}
|
||||
if cmdCtx.Err() == context.DeadlineExceeded {
|
||||
r := fantasy.NewTextErrorResponse("command timed out")
|
||||
return 0, &r
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// executeBashBuffered collects all output before returning (original behavior).
|
||||
// It uses explicit pipes (not cmd.Stdout) so that cmd.WaitDelay can forcibly
|
||||
// close them when grandchild processes hold pipe handles open after the
|
||||
// direct child exits.
|
||||
func executeBashBuffered(cmdCtx context.Context, 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
|
||||
}
|
||||
stderrPipe, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse("failed to create stderr pipe"), nil
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to start command: %v", err)), nil
|
||||
func executeBashBuffered(cmdCtx context.Context, _ fantasy.ToolCall, cmd *exec.Cmd, sudoPassword string) (fantasy.ToolResponse, error) {
|
||||
stdoutPipe, stderrPipe, errResp := setupBashPipes(cmd, sudoPassword)
|
||||
if errResp != nil {
|
||||
return *errResp, nil
|
||||
}
|
||||
|
||||
// Read pipes concurrently
|
||||
var wg sync.WaitGroup
|
||||
var stdout, stderr strings.Builder
|
||||
var stdoutErr, stderrErr error
|
||||
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, stdoutErr = io.Copy(&stdout, stdoutPipe)
|
||||
_, _ = io.Copy(&stdout, stdoutPipe)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, stderrErr = io.Copy(&stderr, stderrPipe)
|
||||
_, _ = io.Copy(&stderr, stderrPipe)
|
||||
}()
|
||||
|
||||
// Wait for the process to exit first. cmd.WaitDelay ensures that if
|
||||
@@ -163,37 +334,19 @@ func executeBashBuffered(cmdCtx context.Context, call fantasy.ToolCall, cmd *exe
|
||||
// Wait for pipe readers to finish draining.
|
||||
wg.Wait()
|
||||
|
||||
// Ignore pipe read errors caused by WaitDelay force-closing —
|
||||
// we still have whatever was read before the close.
|
||||
_ = stdoutErr
|
||||
_ = stderrErr
|
||||
|
||||
exitCode := 0
|
||||
if waitErr != nil {
|
||||
if exitErr, ok := waitErr.(*exec.ExitError); ok {
|
||||
exitCode = exitErr.ExitCode()
|
||||
} else if cmdCtx.Err() == context.DeadlineExceeded {
|
||||
return fantasy.NewTextErrorResponse("command timed out"), nil
|
||||
}
|
||||
exitCode, errResp := interpretBashExit(waitErr, cmdCtx)
|
||||
if errResp != nil {
|
||||
return *errResp, nil
|
||||
}
|
||||
|
||||
return buildBashResponse(stdout.String(), stderr.String(), exitCode)
|
||||
}
|
||||
|
||||
// executeBashStreaming streams output as it arrives via the callback.
|
||||
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
|
||||
}
|
||||
stderrPipe, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse("failed to create stderr pipe"), nil
|
||||
}
|
||||
|
||||
// Start command execution
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to start command: %v", err)), nil
|
||||
func executeBashStreaming(cmdCtx context.Context, call fantasy.ToolCall, cmd *exec.Cmd, outputCallback ToolOutputCallback, sudoPassword string) (fantasy.ToolResponse, error) {
|
||||
stdoutPipe, stderrPipe, errResp := setupBashPipes(cmd, sudoPassword)
|
||||
if errResp != nil {
|
||||
return *errResp, nil
|
||||
}
|
||||
|
||||
// Stream stdout and stderr concurrently
|
||||
@@ -230,20 +383,16 @@ func executeBashStreaming(cmdCtx context.Context, call fantasy.ToolCall, cmd *ex
|
||||
// Wait for the process to exit. cmd.WaitDelay ensures that if pipes
|
||||
// remain open (held by grandchild processes), they'll be forcibly closed
|
||||
// after the grace period, which unblocks the scanners above.
|
||||
err = cmd.Wait()
|
||||
waitErr := cmd.Wait()
|
||||
|
||||
// Wait for the pipe readers to finish draining. This will complete
|
||||
// quickly since cmd.Wait() (with WaitDelay) has already ensured
|
||||
// the pipes are closed.
|
||||
wg.Wait()
|
||||
|
||||
exitCode := 0
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = exitErr.ExitCode()
|
||||
} else if cmdCtx.Err() == context.DeadlineExceeded {
|
||||
return fantasy.NewTextErrorResponse("command timed out"), nil
|
||||
}
|
||||
exitCode, errResp := interpretBashExit(waitErr, cmdCtx)
|
||||
if errResp != nil {
|
||||
return *errResp, nil
|
||||
}
|
||||
|
||||
return buildBashResponse(strings.Join(stdoutChunks, "\n"), strings.Join(stderrChunks, "\n"), exitCode)
|
||||
|
||||
@@ -127,3 +127,72 @@ 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 := context.WithValue(context.Background(), sudoPasswordKey, "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)
|
||||
}
|
||||
}
|
||||
|
||||
+9
-42
@@ -21,12 +21,9 @@ type Edit struct {
|
||||
}
|
||||
|
||||
// editArgs holds the arguments for the edit tool.
|
||||
// Supports both single-edit mode (old_text/new_text) and multi-edit mode (edits array).
|
||||
type editArgs struct {
|
||||
Path string `json:"path"`
|
||||
OldText string `json:"old_text"` // Single-edit mode
|
||||
NewText string `json:"new_text"` // Single-edit mode
|
||||
Edits []Edit `json:"edits"` // Multi-edit mode
|
||||
Path string `json:"path"`
|
||||
Edits []Edit `json:"edits"`
|
||||
}
|
||||
|
||||
// replacement represents a normalized edit ready for processing.
|
||||
@@ -52,20 +49,12 @@ func NewEditTool(opts ...ToolOption) fantasy.AgentTool {
|
||||
return &coreTool{
|
||||
info: fantasy.ToolInfo{
|
||||
Name: "edit",
|
||||
Description: "Edit a file by replacing exact text. Supports single edit via old_text/new_text, or multiple edits via the edits array. All edits in the array are matched against the original file content (non-incremental) and must be non-overlapping.",
|
||||
Description: "Edit a file by replacing exact text. All edits in the array are matched against the original file content (non-incremental) and must be non-overlapping.",
|
||||
Parameters: map[string]any{
|
||||
"path": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Path to the file to edit (relative or absolute)",
|
||||
},
|
||||
"old_text": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Exact text to find and replace (single-edit mode). Must not be used with 'edits' array.",
|
||||
},
|
||||
"new_text": map[string]any{
|
||||
"type": "string",
|
||||
"description": "New text to replace the old text with (single-edit mode). Must not be used with 'edits' array.",
|
||||
},
|
||||
"edits": map[string]any{
|
||||
"type": "array",
|
||||
"description": "Array of edits for multi-region replacement. Each edit must have unique, non-overlapping old_text. All matches are against the original file content.",
|
||||
@@ -85,7 +74,7 @@ func NewEditTool(opts ...ToolOption) fantasy.AgentTool {
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"path"},
|
||||
Required: []string{"path", "edits"},
|
||||
},
|
||||
handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
return executeEdit(ctx, call, cfg.WorkDir)
|
||||
@@ -94,6 +83,9 @@ func NewEditTool(opts ...ToolOption) fantasy.AgentTool {
|
||||
}
|
||||
|
||||
func executeEdit(ctx context.Context, call fantasy.ToolCall, workDir string) (fantasy.ToolResponse, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fantasy.ToolResponse{}, err
|
||||
}
|
||||
var args editArgs
|
||||
if err := parseArgs(call.Input, &args); err != nil {
|
||||
return fantasy.NewTextErrorResponse("failed to parse arguments: " + err.Error()), nil
|
||||
@@ -163,36 +155,11 @@ func executeEdit(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
|
||||
}
|
||||
|
||||
// normalizeEditInput validates and normalizes the edit input.
|
||||
// Returns error if both single-edit and multi-edit modes are used.
|
||||
func normalizeEditInput(args editArgs) ([]replacement, error) {
|
||||
singleMode := args.OldText != "" || args.NewText != ""
|
||||
multiMode := len(args.Edits) > 0
|
||||
|
||||
if singleMode && multiMode {
|
||||
return nil, fmt.Errorf("cannot use old_text/new_text together with edits array")
|
||||
if len(args.Edits) == 0 {
|
||||
return nil, fmt.Errorf("edits array is required and must not be empty")
|
||||
}
|
||||
|
||||
if !singleMode && !multiMode {
|
||||
return nil, fmt.Errorf("must provide either old_text/new_text or edits array")
|
||||
}
|
||||
|
||||
if singleMode {
|
||||
if args.OldText == "" {
|
||||
return nil, fmt.Errorf("old_text is required when using single-edit mode")
|
||||
}
|
||||
if args.NewText == "" {
|
||||
return nil, fmt.Errorf("new_text is required when using single-edit mode")
|
||||
}
|
||||
return []replacement{{
|
||||
oldText: strings.ReplaceAll(args.OldText, "\r\n", "\n"),
|
||||
newText: strings.ReplaceAll(args.NewText, "\r\n", "\n"),
|
||||
originalOld: args.OldText,
|
||||
originalNew: args.NewText,
|
||||
index: 0,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
// Multi-edit mode
|
||||
var reps []replacement
|
||||
for i, edit := range args.Edits {
|
||||
if edit.OldText == "" {
|
||||
|
||||
+62
-44
@@ -389,9 +389,11 @@ func TestExecuteEdit_ExactMatch(t *testing.T) {
|
||||
writeFileOrFail(t, path, original)
|
||||
|
||||
input, _ := json.Marshal(editArgs{
|
||||
Path: path,
|
||||
OldText: "fmt.Println(\"hello\")",
|
||||
NewText: "fmt.Println(\"world\")",
|
||||
Path: path,
|
||||
Edits: []Edit{{
|
||||
OldText: "fmt.Println(\"hello\")",
|
||||
NewText: "fmt.Println(\"world\")",
|
||||
}},
|
||||
})
|
||||
|
||||
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
|
||||
@@ -426,9 +428,11 @@ func TestExecuteEdit_ExactMatch_DoesNotCorruptRest(t *testing.T) {
|
||||
target := lines[49]
|
||||
replacement := "REPLACED_LINE_50"
|
||||
input, _ := json.Marshal(editArgs{
|
||||
Path: path,
|
||||
OldText: target,
|
||||
NewText: replacement,
|
||||
Path: path,
|
||||
Edits: []Edit{{
|
||||
OldText: target,
|
||||
NewText: replacement,
|
||||
}},
|
||||
})
|
||||
|
||||
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
|
||||
@@ -470,9 +474,11 @@ func TestExecuteEdit_FuzzyMatch_TrailingWhitespace(t *testing.T) {
|
||||
|
||||
// Search without trailing whitespace (common LLM behavior)
|
||||
input, _ := json.Marshal(editArgs{
|
||||
Path: path,
|
||||
OldText: "func foo() {\n\treturn 1\n}",
|
||||
NewText: "func foo() {\n\treturn 2\n}",
|
||||
Path: path,
|
||||
Edits: []Edit{{
|
||||
OldText: "func foo() {\n\treturn 1\n}",
|
||||
NewText: "func foo() {\n\treturn 2\n}",
|
||||
}},
|
||||
})
|
||||
|
||||
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
|
||||
@@ -519,9 +525,11 @@ func TestExecuteEdit_FuzzyMatch_DoesNotCorruptRest(t *testing.T) {
|
||||
search := strings.Repeat("x", 10) + "\n" + strings.Repeat("x", 10)
|
||||
// But this matches lines 1-2, 2-3, etc. — should fail due to ambiguity.
|
||||
input, _ := json.Marshal(editArgs{
|
||||
Path: path,
|
||||
OldText: search,
|
||||
NewText: "REPLACED",
|
||||
Path: path,
|
||||
Edits: []Edit{{
|
||||
OldText: search,
|
||||
NewText: "REPLACED",
|
||||
}},
|
||||
})
|
||||
|
||||
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
|
||||
@@ -546,9 +554,11 @@ func TestExecuteEdit_MultipleMatches_Fails(t *testing.T) {
|
||||
writeFileOrFail(t, path, "hello\nworld\nhello\n")
|
||||
|
||||
input, _ := json.Marshal(editArgs{
|
||||
Path: path,
|
||||
OldText: "hello",
|
||||
NewText: "goodbye",
|
||||
Path: path,
|
||||
Edits: []Edit{{
|
||||
OldText: "hello",
|
||||
NewText: "goodbye",
|
||||
}},
|
||||
})
|
||||
|
||||
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
|
||||
@@ -575,9 +585,11 @@ func TestExecuteEdit_NoMatch_Fails(t *testing.T) {
|
||||
writeFileOrFail(t, path, "hello world\n")
|
||||
|
||||
input, _ := json.Marshal(editArgs{
|
||||
Path: path,
|
||||
OldText: "nonexistent text",
|
||||
NewText: "replacement",
|
||||
Path: path,
|
||||
Edits: []Edit{{
|
||||
OldText: "nonexistent text",
|
||||
NewText: "replacement",
|
||||
}},
|
||||
})
|
||||
|
||||
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
|
||||
@@ -601,9 +613,11 @@ func TestExecuteEdit_CRLFNormalization(t *testing.T) {
|
||||
writeFileOrFail(t, path, "line1\r\nline2\r\nline3\r\n")
|
||||
|
||||
input, _ := json.Marshal(editArgs{
|
||||
Path: path,
|
||||
OldText: "line2",
|
||||
NewText: "LINE2",
|
||||
Path: path,
|
||||
Edits: []Edit{{
|
||||
OldText: "line2",
|
||||
NewText: "LINE2",
|
||||
}},
|
||||
})
|
||||
|
||||
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
|
||||
@@ -622,8 +636,10 @@ func TestExecuteEdit_CRLFNormalization(t *testing.T) {
|
||||
|
||||
func TestExecuteEdit_MissingPath(t *testing.T) {
|
||||
input, _ := json.Marshal(editArgs{
|
||||
OldText: "x",
|
||||
NewText: "y",
|
||||
Edits: []Edit{{
|
||||
OldText: "x",
|
||||
NewText: "y",
|
||||
}},
|
||||
})
|
||||
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, "")
|
||||
if err != nil {
|
||||
@@ -636,9 +652,11 @@ func TestExecuteEdit_MissingPath(t *testing.T) {
|
||||
|
||||
func TestExecuteEdit_NonexistentFile(t *testing.T) {
|
||||
input, _ := json.Marshal(editArgs{
|
||||
Path: "/tmp/nonexistent_edit_test_file_12345.go",
|
||||
OldText: "x",
|
||||
NewText: "y",
|
||||
Path: "/tmp/nonexistent_edit_test_file_12345.go",
|
||||
Edits: []Edit{{
|
||||
OldText: "x",
|
||||
NewText: "y",
|
||||
}},
|
||||
})
|
||||
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, "")
|
||||
if err != nil {
|
||||
@@ -661,9 +679,11 @@ func TestExecuteEdit_DiffContainsHunkHeader(t *testing.T) {
|
||||
writeFileOrFail(t, path, strings.Join(lines, "\n")+"\n")
|
||||
|
||||
input, _ := json.Marshal(editArgs{
|
||||
Path: path,
|
||||
OldText: "line_10_content",
|
||||
NewText: "REPLACED",
|
||||
Path: path,
|
||||
Edits: []Edit{{
|
||||
OldText: "line_10_content",
|
||||
NewText: "REPLACED",
|
||||
}},
|
||||
})
|
||||
|
||||
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
|
||||
@@ -684,9 +704,11 @@ func TestExecuteEdit_MetadataContainsFileDiffs(t *testing.T) {
|
||||
writeFileOrFail(t, path, "old content\n")
|
||||
|
||||
input, _ := json.Marshal(editArgs{
|
||||
Path: path,
|
||||
OldText: "old content",
|
||||
NewText: "new content",
|
||||
Path: path,
|
||||
Edits: []Edit{{
|
||||
OldText: "old content",
|
||||
NewText: "new content",
|
||||
}},
|
||||
})
|
||||
|
||||
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
|
||||
@@ -905,18 +927,14 @@ func TestExecuteEdit_MultiEdit_EmptyArray(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteEdit_MultiEdit_MixedWithSingleMode(t *testing.T) {
|
||||
func TestExecuteEdit_EmptyEditsArray_Fails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "mixed.txt")
|
||||
path := filepath.Join(dir, "empty.txt")
|
||||
writeFileOrFail(t, path, "hello\n")
|
||||
|
||||
input, _ := json.Marshal(map[string]any{
|
||||
"path": path,
|
||||
"old_text": "hello",
|
||||
"new_text": "HELLO",
|
||||
"edits": []Edit{
|
||||
{OldText: "hello", NewText: "HI"},
|
||||
},
|
||||
input, _ := json.Marshal(editArgs{
|
||||
Path: path,
|
||||
Edits: []Edit{},
|
||||
})
|
||||
|
||||
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
|
||||
@@ -924,10 +942,10 @@ func TestExecuteEdit_MultiEdit_MixedWithSingleMode(t *testing.T) {
|
||||
t.Fatalf("executeEdit error: %v", err)
|
||||
}
|
||||
if !resp.IsError {
|
||||
t.Error("expected error when mixing single and multi-edit modes")
|
||||
t.Error("expected error for empty edits array")
|
||||
}
|
||||
if !strings.Contains(resp.Content, "cannot use") {
|
||||
t.Errorf("expected 'cannot use' in error, got: %s", resp.Content)
|
||||
if !strings.Contains(resp.Content, "required") {
|
||||
t.Errorf("expected 'required' in error, got: %s", resp.Content)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,9 @@ func NewLsTool(opts ...ToolOption) fantasy.AgentTool {
|
||||
}
|
||||
|
||||
func executeLs(ctx context.Context, call fantasy.ToolCall, workDir string) (fantasy.ToolResponse, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fantasy.ToolResponse{}, err
|
||||
}
|
||||
var args lsArgs
|
||||
_ = parseArgs(call.Input, &args) // optional args
|
||||
|
||||
|
||||
@@ -47,6 +47,9 @@ func NewReadTool(opts ...ToolOption) fantasy.AgentTool {
|
||||
}
|
||||
|
||||
func executeRead(ctx context.Context, call fantasy.ToolCall, workDir string) (fantasy.ToolResponse, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fantasy.ToolResponse{}, err
|
||||
}
|
||||
var args readArgs
|
||||
if err := parseArgs(call.Input, &args); err != nil {
|
||||
return fantasy.NewTextErrorResponse("path parameter is required"), nil
|
||||
|
||||
@@ -86,7 +86,7 @@ Example use cases:
|
||||
},
|
||||
"model": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional model override (e.g. 'anthropic/claude-haiku-3-5-20241022' for faster/cheaper tasks)",
|
||||
"description": "Optional model override. Empty string uses the current model.",
|
||||
},
|
||||
"system_prompt": map[string]any{
|
||||
"type": "string",
|
||||
@@ -94,7 +94,7 @@ Example use cases:
|
||||
},
|
||||
"timeout_seconds": map[string]any{
|
||||
"type": "number",
|
||||
"description": "Maximum execution time in seconds (default: 300, max: 1800)",
|
||||
"description": "Maximum execution time in seconds (default: 300, max: 1800, minimum recommended: 240)",
|
||||
},
|
||||
},
|
||||
Required: []string{"task"},
|
||||
|
||||
@@ -41,6 +41,9 @@ func NewWriteTool(opts ...ToolOption) fantasy.AgentTool {
|
||||
}
|
||||
|
||||
func executeWrite(ctx context.Context, call fantasy.ToolCall, workDir string) (fantasy.ToolResponse, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fantasy.ToolResponse{}, err
|
||||
}
|
||||
var args writeArgs
|
||||
if err := parseArgs(call.Input, &args); err != nil {
|
||||
return fantasy.NewTextErrorResponse("path and content parameters are required"), nil
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package extbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
)
|
||||
|
||||
// BaseContext returns an extensions.Context populated with the headless,
|
||||
// TUI-independent delegation fields: data access, state, options,
|
||||
// model/tool management, completions, subagents, tree navigation, skills,
|
||||
// template parsing, and model resolution.
|
||||
//
|
||||
// Callers overlay their UI-specific fields (print routes, widgets, prompts,
|
||||
// editor, TUI-aware SetModel/ReloadExtensions, etc.) on the returned value:
|
||||
// cmd/extension_context.go for the interactive TUI and
|
||||
// internal/acpserver/session.go for headless ACP mode. Keeping the shared
|
||||
// half here means a new data-access Context field only has to be wired once.
|
||||
//
|
||||
// ctx is used for subagent spawns; pass a long-lived context (not a
|
||||
// per-request one) so later spawns aren't cancelled prematurely.
|
||||
func BaseContext(ctx context.Context, kitInstance *kit.Kit) extensions.Context {
|
||||
return extensions.Context{
|
||||
// -------------------------------------------------------------------
|
||||
// Data access
|
||||
// -------------------------------------------------------------------
|
||||
GetContextStats: func() extensions.ContextStats {
|
||||
s := kitInstance.GetContextStats()
|
||||
return extensions.ContextStats{
|
||||
EstimatedTokens: s.EstimatedTokens,
|
||||
ContextLimit: s.ContextLimit,
|
||||
UsagePercent: s.UsagePercent,
|
||||
MessageCount: s.MessageCount,
|
||||
}
|
||||
},
|
||||
GetMessages: func() []extensions.SessionMessage {
|
||||
return kitInstance.Extensions().GetSessionMessages()
|
||||
},
|
||||
GetSessionPath: func() string {
|
||||
return kitInstance.GetSessionPath()
|
||||
},
|
||||
AppendEntry: func(entryType string, data string) (string, error) {
|
||||
return kitInstance.Extensions().AppendEntry(entryType, data)
|
||||
},
|
||||
GetEntries: func(entryType string) []extensions.ExtensionEntry {
|
||||
return kitInstance.Extensions().GetEntries(entryType)
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Extension state
|
||||
// -------------------------------------------------------------------
|
||||
SetState: func(key string, value string) {
|
||||
kitInstance.Extensions().SetState(key, value)
|
||||
},
|
||||
GetState: func(key string) (string, bool) {
|
||||
return kitInstance.Extensions().GetState(key)
|
||||
},
|
||||
DeleteState: func(key string) {
|
||||
kitInstance.Extensions().DeleteState(key)
|
||||
},
|
||||
ListState: func() []string {
|
||||
return kitInstance.Extensions().ListState()
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Options, model, and tool management
|
||||
// -------------------------------------------------------------------
|
||||
GetOption: func(name string) string {
|
||||
return kitInstance.Extensions().GetOption(name)
|
||||
},
|
||||
SetOption: func(name string, value string) {
|
||||
kitInstance.Extensions().SetOption(name, value)
|
||||
},
|
||||
// Headless model switch. The interactive TUI overrides this with a
|
||||
// version that also notifies the TUI and refreshes the usage tracker.
|
||||
SetModel: func(modelString string) error {
|
||||
previousModel := kitInstance.Extensions().GetContext().Model
|
||||
if err := kitInstance.SetModel(context.Background(), modelString); err != nil {
|
||||
return err
|
||||
}
|
||||
kitInstance.Extensions().UpdateContextModel(modelString)
|
||||
kitInstance.Extensions().EmitModelChange(modelString, previousModel, "extension")
|
||||
return nil
|
||||
},
|
||||
GetAvailableModels: func() []extensions.ModelInfoEntry {
|
||||
return kitInstance.GetAvailableModels()
|
||||
},
|
||||
EmitCustomEvent: func(name string, data string) {
|
||||
kitInstance.Extensions().EmitCustomEvent(name, data)
|
||||
},
|
||||
GetAllTools: func() []extensions.ToolInfo {
|
||||
return kitInstance.Extensions().GetToolInfos()
|
||||
},
|
||||
SetActiveTools: func(names []string) {
|
||||
kitInstance.Extensions().SetActiveTools(names)
|
||||
},
|
||||
// Headless reload. The interactive TUI overrides this to also
|
||||
// refresh widgets/status/commands.
|
||||
ReloadExtensions: func() error {
|
||||
return kitInstance.Extensions().Reload()
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// LLM completions and subagents
|
||||
// -------------------------------------------------------------------
|
||||
Complete: func(req extensions.CompleteRequest) (extensions.CompleteResponse, error) {
|
||||
return kitInstance.ExecuteCompletion(context.Background(), req)
|
||||
},
|
||||
SpawnSubagent: func(config extensions.SubagentConfig) (*extensions.SubagentHandle, *extensions.SubagentResult, error) {
|
||||
return SpawnSubagent(ctx, kitInstance, config)
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Tree Navigation API
|
||||
// -------------------------------------------------------------------
|
||||
GetTreeNode: func(entryID string) *extensions.TreeNode {
|
||||
node := kitInstance.GetTreeNode(entryID)
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
return &extensions.TreeNode{
|
||||
ID: node.ID,
|
||||
ParentID: node.ParentID,
|
||||
Type: node.Type,
|
||||
Role: node.Role,
|
||||
Content: node.Content,
|
||||
Model: node.Model,
|
||||
Provider: node.Provider,
|
||||
Timestamp: node.Timestamp,
|
||||
Children: node.Children,
|
||||
}
|
||||
},
|
||||
GetCurrentBranch: func() []extensions.TreeNode {
|
||||
nodes := kitInstance.GetCurrentBranch()
|
||||
result := make([]extensions.TreeNode, len(nodes))
|
||||
for i, n := range nodes {
|
||||
result[i] = extensions.TreeNode{
|
||||
ID: n.ID,
|
||||
ParentID: n.ParentID,
|
||||
Type: n.Type,
|
||||
Role: n.Role,
|
||||
Content: n.Content,
|
||||
Model: n.Model,
|
||||
Provider: n.Provider,
|
||||
Timestamp: n.Timestamp,
|
||||
Children: n.Children,
|
||||
}
|
||||
}
|
||||
return result
|
||||
},
|
||||
GetChildren: func(parentID string) []string {
|
||||
return kitInstance.GetChildren(parentID)
|
||||
},
|
||||
NavigateTo: func(entryID string) extensions.TreeNavigationResult {
|
||||
err := kitInstance.NavigateTo(entryID)
|
||||
if err != nil {
|
||||
return extensions.TreeNavigationResult{Success: false, Error: err.Error()}
|
||||
}
|
||||
return extensions.TreeNavigationResult{Success: true}
|
||||
},
|
||||
SummarizeBranch: func(fromID, toID string) string {
|
||||
summary, _ := kitInstance.SummarizeBranch(fromID, toID)
|
||||
return summary
|
||||
},
|
||||
CollapseBranch: func(fromID, toID, summary string) extensions.TreeNavigationResult {
|
||||
err := kitInstance.CollapseBranch(fromID, toID, summary)
|
||||
if err != nil {
|
||||
return extensions.TreeNavigationResult{Success: false, Error: err.Error()}
|
||||
}
|
||||
return extensions.TreeNavigationResult{Success: true}
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Skill Loading API (context-injection variants are TUI-specific and
|
||||
// wired by the interactive overlay)
|
||||
// -------------------------------------------------------------------
|
||||
LoadSkill: func(path string) (*extensions.Skill, string) {
|
||||
s, err := kitInstance.LoadSkillForExtension(path)
|
||||
return s, err
|
||||
},
|
||||
LoadSkillsFromDir: func(dir string) extensions.SkillLoadResult {
|
||||
return kitInstance.LoadSkillsFromDirForExtension(dir)
|
||||
},
|
||||
DiscoverSkills: func() extensions.SkillLoadResult {
|
||||
skills := kitInstance.DiscoverSkillsForExtension()
|
||||
return extensions.SkillLoadResult{Skills: skills}
|
||||
},
|
||||
GetAvailableSkills: func() []extensions.Skill {
|
||||
return kitInstance.DiscoverSkillsForExtension()
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Template Parsing API
|
||||
// -------------------------------------------------------------------
|
||||
ParseTemplate: func(name, content string) extensions.PromptTemplate {
|
||||
return kit.ParseTemplate(name, content)
|
||||
},
|
||||
RenderTemplate: func(tpl extensions.PromptTemplate, vars map[string]string) string {
|
||||
return kit.RenderTemplate(tpl, vars)
|
||||
},
|
||||
ParseArguments: func(input string, pattern extensions.ArgumentPattern) extensions.ParseResult {
|
||||
return kit.ParseArguments(input, pattern)
|
||||
},
|
||||
SimpleParseArguments: func(input string, count int) []string {
|
||||
return kit.SimpleParseArguments(input, count)
|
||||
},
|
||||
EvaluateModelConditional: func(condition string) bool {
|
||||
return kit.EvaluateModelConditional(kitInstance.Extensions().GetContext().Model, condition)
|
||||
},
|
||||
RenderWithModelConditionals: func(content string) string {
|
||||
return kit.RenderWithModelConditionals(content, kitInstance.Extensions().GetContext().Model)
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Model Resolution API
|
||||
// -------------------------------------------------------------------
|
||||
ResolveModelChain: func(preferences []string) extensions.ModelResolutionResult {
|
||||
return kit.ResolveModelChain(preferences)
|
||||
},
|
||||
GetModelCapabilities: func(model string) (extensions.ModelCapabilities, string) {
|
||||
return kit.GetModelCapabilities(model)
|
||||
},
|
||||
CheckModelAvailable: func(model string) bool {
|
||||
return kit.CheckModelAvailable(model)
|
||||
},
|
||||
GetCurrentProvider: func() string {
|
||||
return kit.GetCurrentProvider(kitInstance.Extensions().GetContext().Model)
|
||||
},
|
||||
GetCurrentModelID: func() string {
|
||||
return kit.GetCurrentModelID(kitInstance.Extensions().GetContext().Model)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Package extbridge wires the public Kit SDK to the internal extensions
|
||||
// package. It exists so that cmd/ and internal/acpserver/ don't both
|
||||
// reimplement the same SDK→extension event/subagent conversions.
|
||||
package extbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
)
|
||||
|
||||
// SDKEventToSubagentEvent converts an SDK [kit.Event] into the
|
||||
// extension-facing [extensions.SubagentEvent]. Returns a zero-value event
|
||||
// (Type=="") for events that don't map to anything useful — callers should
|
||||
// drop those.
|
||||
func SDKEventToSubagentEvent(e kit.Event) extensions.SubagentEvent {
|
||||
switch ev := e.(type) {
|
||||
case kit.MessageUpdateEvent:
|
||||
return extensions.SubagentEvent{Type: "text", Content: ev.Chunk}
|
||||
case kit.ReasoningDeltaEvent:
|
||||
return extensions.SubagentEvent{Type: "reasoning", Content: ev.Delta}
|
||||
case kit.ToolCallEvent:
|
||||
return extensions.SubagentEvent{
|
||||
Type: "tool_call", ToolCallID: ev.ToolCallID,
|
||||
ToolName: ev.ToolName, ToolKind: ev.ToolKind, ToolArgs: ev.ToolArgs,
|
||||
}
|
||||
case kit.ToolExecutionStartEvent:
|
||||
return extensions.SubagentEvent{
|
||||
Type: "tool_execution_start", ToolCallID: ev.ToolCallID,
|
||||
ToolName: ev.ToolName, ToolKind: ev.ToolKind,
|
||||
}
|
||||
case kit.ToolExecutionEndEvent:
|
||||
return extensions.SubagentEvent{
|
||||
Type: "tool_execution_end", ToolCallID: ev.ToolCallID,
|
||||
ToolName: ev.ToolName, ToolKind: ev.ToolKind,
|
||||
}
|
||||
case kit.ToolResultEvent:
|
||||
return extensions.SubagentEvent{
|
||||
Type: "tool_result", ToolCallID: ev.ToolCallID,
|
||||
ToolName: ev.ToolName, ToolKind: ev.ToolKind,
|
||||
ToolResult: ev.Result, IsError: ev.IsError,
|
||||
}
|
||||
case kit.TurnStartEvent:
|
||||
return extensions.SubagentEvent{Type: "turn_start"}
|
||||
case kit.TurnEndEvent:
|
||||
return extensions.SubagentEvent{Type: "turn_end"}
|
||||
default:
|
||||
return extensions.SubagentEvent{}
|
||||
}
|
||||
}
|
||||
|
||||
// SpawnSubagent runs a subagent in-process via the Kit SDK and translates
|
||||
// the result/events back into the extension-facing types. The returned
|
||||
// handle is always nil — the SDK path runs synchronously and does not
|
||||
// expose a separate process handle. Callers that need non-blocking
|
||||
// behaviour should run this in their own goroutine.
|
||||
//
|
||||
// This function consolidates the previously-duplicated wiring in
|
||||
// cmd/root.go (interactive + runtime contexts) and
|
||||
// internal/acpserver/session.go.
|
||||
func SpawnSubagent(ctx context.Context, k *kit.Kit, cfg extensions.SubagentConfig) (*extensions.SubagentHandle, *extensions.SubagentResult, error) {
|
||||
sdkCfg := kit.SubagentConfig{
|
||||
Prompt: cfg.Prompt,
|
||||
Model: cfg.Model,
|
||||
SystemPrompt: cfg.SystemPrompt,
|
||||
Timeout: cfg.Timeout,
|
||||
NoSession: cfg.NoSession,
|
||||
Tools: k.GetToolsForSubagent(),
|
||||
}
|
||||
if cfg.OnEvent != nil {
|
||||
sdkCfg.OnEvent = func(e kit.Event) {
|
||||
se := SDKEventToSubagentEvent(e)
|
||||
if se.Type != "" {
|
||||
cfg.OnEvent(se)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result, err := k.Subagent(ctx, sdkCfg)
|
||||
if result == nil {
|
||||
return nil, &extensions.SubagentResult{Error: err}, err
|
||||
}
|
||||
|
||||
extResult := &extensions.SubagentResult{
|
||||
Response: result.Response,
|
||||
Error: err,
|
||||
SessionID: result.SessionID,
|
||||
Elapsed: result.Elapsed,
|
||||
}
|
||||
if result.Usage != nil {
|
||||
extResult.Usage = &extensions.SubagentUsage{
|
||||
InputTokens: result.Usage.InputTokens,
|
||||
OutputTokens: result.Usage.OutputTokens,
|
||||
}
|
||||
}
|
||||
return nil, extResult, err
|
||||
}
|
||||
+336
-1
@@ -341,6 +341,13 @@ type Context struct {
|
||||
// The data survives across session restarts and can be retrieved via
|
||||
// GetEntries. Use entryType to namespace your data (e.g. "myext:state").
|
||||
//
|
||||
// AppendEntry is append-only and lives in the conversation tree, which
|
||||
// makes it the right tool for audit logs and event histories. For
|
||||
// last-write-wins snapshot state — "what's the current value of X?" —
|
||||
// prefer SetState / GetState instead. Those primitives store data in a
|
||||
// sidecar file outside the conversation tree, are O(1) to read/write,
|
||||
// and do not bloat branch reads or duplicate on fork.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// data, _ := json.Marshal(myState)
|
||||
@@ -360,6 +367,45 @@ type Context struct {
|
||||
// }
|
||||
GetEntries func(entryType string) []ExtensionEntry
|
||||
|
||||
// SetState stores a key-value pair in session-scoped, last-write-wins
|
||||
// extension state. Unlike AppendEntry the value is kept in a sidecar
|
||||
// file outside the conversation tree, so:
|
||||
// - reads are O(1) (no branch walk)
|
||||
// - writes don't bloat the session JSONL
|
||||
// - state is not duplicated on fork (branches share the sidecar)
|
||||
// - state is invisible to the LLM
|
||||
//
|
||||
// Use SetState for snapshot state ("current value of X"); use
|
||||
// AppendEntry for audit logs and event histories. Namespace keys with
|
||||
// your extension name to avoid collisions (e.g. "myext:budget-cap").
|
||||
//
|
||||
// State persists for the lifetime of the session. For ephemeral or
|
||||
// in-memory sessions the state lives only in memory.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// ctx.SetState("myext:budget-cap", "10.00")
|
||||
SetState func(key string, value string)
|
||||
|
||||
// GetState returns the value previously stored via SetState. The bool
|
||||
// is false when the key was never written. Returns ("", false) when
|
||||
// state is unavailable.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// if cap, ok := ctx.GetState("myext:budget-cap"); ok {
|
||||
// fmt.Println("current cap:", cap)
|
||||
// }
|
||||
GetState func(key string) (string, bool)
|
||||
|
||||
// DeleteState removes a key from session-scoped extension state.
|
||||
// No-op when the key is missing.
|
||||
DeleteState func(key string)
|
||||
|
||||
// ListState returns all keys currently stored in session-scoped
|
||||
// extension state, in unspecified order.
|
||||
ListState func() []string
|
||||
|
||||
// SetEditorText sets the text content of the input editor. This can
|
||||
// be used to pre-fill the editor with suggested text (e.g. extracted
|
||||
// questions, handoff prompts). The cursor is moved to the end.
|
||||
@@ -1063,6 +1109,9 @@ type PrintBlockOpts struct {
|
||||
type API struct {
|
||||
// Event-specific registration functions (wired by the loader).
|
||||
onToolCall func(func(ToolCallEvent, Context) *ToolCallResult)
|
||||
onToolCallInputStart func(func(ToolCallInputStartEvent, Context))
|
||||
onToolCallInputDelta func(func(ToolCallInputDeltaEvent, Context))
|
||||
onToolCallInputEnd func(func(ToolCallInputEndEvent, Context))
|
||||
onToolExecStart func(func(ToolExecutionStartEvent, Context))
|
||||
onToolExecEnd func(func(ToolExecutionEndEvent, Context))
|
||||
onToolOutput func(func(ToolOutputEvent, Context))
|
||||
@@ -1091,6 +1140,15 @@ type API struct {
|
||||
onSubagentStart func(func(SubagentStartEvent, Context))
|
||||
onSubagentChunk func(func(SubagentChunkEvent, Context))
|
||||
onSubagentEnd func(func(SubagentEndEvent, Context))
|
||||
onStepStart func(func(StepStartEvent, Context))
|
||||
onStepFinish func(func(StepFinishEvent, Context))
|
||||
onReasoningStart func(func(ReasoningStartEvent, Context))
|
||||
onWarnings func(func(WarningsEvent, Context))
|
||||
onSource func(func(SourceEvent, Context))
|
||||
onError func(func(ErrorEvent, Context))
|
||||
onRetry func(func(RetryEvent, Context))
|
||||
onPrepareStep func(func(PrepareStepEvent, Context) *PrepareStepResult)
|
||||
onLLMUsage func(func(LLMUsageEvent, Context))
|
||||
}
|
||||
|
||||
// OnToolCall registers a handler that fires before a tool executes.
|
||||
@@ -1099,6 +1157,26 @@ func (a *API) OnToolCall(handler func(ToolCallEvent, Context) *ToolCallResult) {
|
||||
a.onToolCall(handler)
|
||||
}
|
||||
|
||||
// OnToolCallInputStart registers a handler that fires when the LLM begins
|
||||
// generating tool call arguments. The tool name is known but the full
|
||||
// argument JSON is still being streamed. Useful for showing a "running"
|
||||
// indicator immediately without waiting for the full arguments.
|
||||
func (a *API) OnToolCallInputStart(handler func(ToolCallInputStartEvent, Context)) {
|
||||
a.onToolCallInputStart(handler)
|
||||
}
|
||||
|
||||
// OnToolCallInputDelta registers a handler that fires for each streamed
|
||||
// fragment of tool call arguments as they arrive from the LLM.
|
||||
func (a *API) OnToolCallInputDelta(handler func(ToolCallInputDeltaEvent, Context)) {
|
||||
a.onToolCallInputDelta(handler)
|
||||
}
|
||||
|
||||
// OnToolCallInputEnd registers a handler that fires when tool argument
|
||||
// streaming is complete, before the tool call is parsed and execution begins.
|
||||
func (a *API) OnToolCallInputEnd(handler func(ToolCallInputEndEvent, Context)) {
|
||||
a.onToolCallInputEnd(handler)
|
||||
}
|
||||
|
||||
// OnToolExecutionStart registers a handler for tool execution start.
|
||||
func (a *API) OnToolExecutionStart(handler func(ToolExecutionStartEvent, Context)) {
|
||||
a.onToolExecStart(handler)
|
||||
@@ -1278,6 +1356,69 @@ func (a *API) OnBeforeCompact(handler func(BeforeCompactEvent, Context) *BeforeC
|
||||
a.onBeforeCompact(handler)
|
||||
}
|
||||
|
||||
// OnStepStart registers a handler that fires when a new LLM call begins
|
||||
// within a multi-step agent turn.
|
||||
func (a *API) OnStepStart(handler func(StepStartEvent, Context)) {
|
||||
a.onStepStart(handler)
|
||||
}
|
||||
|
||||
// OnStepFinish registers a handler that fires when a step completes,
|
||||
// providing step number, finish reason, and decomposed token usage.
|
||||
func (a *API) OnStepFinish(handler func(StepFinishEvent, Context)) {
|
||||
a.onStepFinish(handler)
|
||||
}
|
||||
|
||||
// OnReasoningStart registers a handler that fires when the LLM begins
|
||||
// reasoning/thinking.
|
||||
func (a *API) OnReasoningStart(handler func(ReasoningStartEvent, Context)) {
|
||||
a.onReasoningStart(handler)
|
||||
}
|
||||
|
||||
// OnWarnings registers a handler that fires when the LLM provider returns
|
||||
// warnings about the request.
|
||||
func (a *API) OnWarnings(handler func(WarningsEvent, Context)) {
|
||||
a.onWarnings(handler)
|
||||
}
|
||||
|
||||
// OnSource registers a handler that fires when the LLM references a source
|
||||
// (e.g. from web search tools).
|
||||
func (a *API) OnSource(handler func(SourceEvent, Context)) {
|
||||
a.onSource(handler)
|
||||
}
|
||||
|
||||
// OnError registers a handler that fires when an agent-level error occurs
|
||||
// during streaming.
|
||||
func (a *API) OnError(handler func(ErrorEvent, Context)) {
|
||||
a.onError(handler)
|
||||
}
|
||||
|
||||
// OnRetry registers a handler that fires when the LLM provider request is
|
||||
// retried after a transient error.
|
||||
func (a *API) OnRetry(handler func(RetryEvent, Context)) {
|
||||
a.onRetry(handler)
|
||||
}
|
||||
|
||||
// OnPrepareStep registers a handler that fires between steps within a
|
||||
// multi-step agent turn, after steering messages are injected and before
|
||||
// messages are sent to the LLM. Return a non-nil PrepareStepResult with
|
||||
// Messages to replace the context window for this step.
|
||||
func (a *API) OnPrepareStep(handler func(PrepareStepEvent, Context) *PrepareStepResult) {
|
||||
a.onPrepareStep(handler)
|
||||
}
|
||||
|
||||
// OnLLMUsage registers a handler that fires after each LLM provider call
|
||||
// with the token and cost deltas for that single call. Use this for
|
||||
// per-call usage attribution, real-time budget enforcement, and cost
|
||||
// dashboards that need to react between calls within a single agent turn.
|
||||
//
|
||||
// Handlers receive an LLMUsageEvent describing the call's input/output
|
||||
// tokens, cache tokens, computed cost, model, and provider. A single agent
|
||||
// turn typically fires multiple LLMUsageEvents (one per tool-loop
|
||||
// iteration).
|
||||
func (a *API) OnLLMUsage(handler func(LLMUsageEvent, Context)) {
|
||||
a.onLLMUsage(handler)
|
||||
}
|
||||
|
||||
// RegisterToolRenderer registers a custom renderer for a specific tool's
|
||||
// display in the TUI. The renderer controls the header (parameter summary)
|
||||
// and/or body (result display) of the tool's output block. If multiple
|
||||
@@ -1890,6 +2031,34 @@ type ToolCallResult struct {
|
||||
|
||||
func (ToolCallResult) isResult() {}
|
||||
|
||||
// ToolCallInputStartEvent fires when the LLM begins generating tool call
|
||||
// arguments. The tool name is known but the full argument JSON is still
|
||||
// being streamed.
|
||||
type ToolCallInputStartEvent struct {
|
||||
ToolCallID string
|
||||
ToolName string
|
||||
ToolKind string // Tool classification: "execute", "edit", "read", "search", "agent"
|
||||
}
|
||||
|
||||
func (e ToolCallInputStartEvent) Type() EventType { return ToolCallInputStart }
|
||||
|
||||
// ToolCallInputDeltaEvent fires for each streamed fragment of tool call
|
||||
// arguments as they arrive from the LLM.
|
||||
type ToolCallInputDeltaEvent struct {
|
||||
ToolCallID string
|
||||
Delta string // JSON fragment of tool arguments
|
||||
}
|
||||
|
||||
func (e ToolCallInputDeltaEvent) Type() EventType { return ToolCallInputDelta }
|
||||
|
||||
// ToolCallInputEndEvent fires when tool argument streaming is complete,
|
||||
// before the tool call is parsed and execution begins.
|
||||
type ToolCallInputEndEvent struct {
|
||||
ToolCallID string
|
||||
}
|
||||
|
||||
func (e ToolCallInputEndEvent) Type() EventType { return ToolCallInputEnd }
|
||||
|
||||
// ToolExecutionStartEvent fires when a tool begins executing.
|
||||
type ToolExecutionStartEvent struct {
|
||||
ToolCallID string
|
||||
@@ -1982,10 +2151,47 @@ type AgentStartEvent struct {
|
||||
|
||||
func (e AgentStartEvent) Type() EventType { return AgentStart }
|
||||
|
||||
// AgentEndEvent fires when the agent finishes responding.
|
||||
// AgentEndEvent fires when the agent finishes responding. In addition to the
|
||||
// final response and stop reason, the event carries per-turn aggregates so
|
||||
// observer-style extensions don't have to maintain parallel bookkeeping in
|
||||
// OnToolResult / OnStepFinish handlers.
|
||||
type AgentEndEvent struct {
|
||||
Response string
|
||||
StopReason string // "completed", "cancelled", "error"
|
||||
|
||||
// ToolCallCount is the total number of tool invocations observed during
|
||||
// this turn (sum across all steps).
|
||||
ToolCallCount int
|
||||
|
||||
// ToolNames lists the tool names invoked during this turn, in call order.
|
||||
// Duplicates are preserved (e.g. two bash calls produce ["bash", "bash"]).
|
||||
ToolNames []string
|
||||
|
||||
// LLMCallCount is the number of LLM round-trips (tool-loop iterations)
|
||||
// performed during this turn. Always >= 1 for a successful turn.
|
||||
LLMCallCount int
|
||||
|
||||
// InputTokensDelta is the sum of input tokens consumed during this turn
|
||||
// across every LLM call (including cache-hit input tokens).
|
||||
InputTokensDelta int
|
||||
|
||||
// OutputTokensDelta is the sum of output tokens generated during this turn.
|
||||
OutputTokensDelta int
|
||||
|
||||
// CacheReadTokensDelta is the sum of cache-read tokens during this turn.
|
||||
CacheReadTokensDelta int
|
||||
|
||||
// CacheWriteTokensDelta is the sum of cache-write tokens during this turn.
|
||||
CacheWriteTokensDelta int
|
||||
|
||||
// CostDelta is the total cost in USD attributable to this turn. Computed
|
||||
// from per-step usage and current model pricing. Zero when pricing is
|
||||
// unknown or OAuth credentials are in use.
|
||||
CostDelta float64
|
||||
|
||||
// DurationMs is the elapsed wall-clock time from AgentStart to AgentEnd,
|
||||
// in milliseconds.
|
||||
DurationMs int64
|
||||
}
|
||||
|
||||
func (e AgentEndEvent) Type() EventType { return AgentEnd }
|
||||
@@ -2202,6 +2408,135 @@ type SubagentEndEvent struct {
|
||||
|
||||
func (e SubagentEndEvent) Type() EventType { return SubagentEnd }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step lifecycle events (exposed to Yaegi — concrete structs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// StepStartEvent fires when a new LLM call begins within a multi-step agent turn.
|
||||
type StepStartEvent struct {
|
||||
StepNumber int
|
||||
}
|
||||
|
||||
func (e StepStartEvent) Type() EventType { return StepStart }
|
||||
|
||||
// StepFinishEvent fires when a step completes, providing step metadata and
|
||||
// token usage. Usage fields are plain int64 (not LLMUsage) because Yaegi
|
||||
// cannot handle fantasy types across the interpreter boundary.
|
||||
type StepFinishEvent struct {
|
||||
StepNumber int
|
||||
HasToolCalls bool
|
||||
FinishReason string
|
||||
InputTokens int64
|
||||
OutputTokens int64
|
||||
CacheReadTokens int64
|
||||
CacheWriteTokens int64
|
||||
}
|
||||
|
||||
func (e StepFinishEvent) Type() EventType { return StepFinish }
|
||||
|
||||
// ReasoningStartEvent fires when the LLM begins reasoning/thinking.
|
||||
type ReasoningStartEvent struct {
|
||||
ID string
|
||||
}
|
||||
|
||||
func (e ReasoningStartEvent) Type() EventType { return ReasoningStart }
|
||||
|
||||
// WarningsEvent fires when the LLM provider returns warnings about the request.
|
||||
type WarningsEvent struct {
|
||||
Warnings []string
|
||||
}
|
||||
|
||||
func (e WarningsEvent) Type() EventType { return Warnings }
|
||||
|
||||
// SourceEvent fires when the LLM references a source (e.g. from web search).
|
||||
type SourceEvent struct {
|
||||
SourceType string
|
||||
ID string
|
||||
URL string
|
||||
Title string
|
||||
}
|
||||
|
||||
func (e SourceEvent) Type() EventType { return Source }
|
||||
|
||||
// ErrorEvent fires when an agent-level error occurs during streaming.
|
||||
// Uses string instead of error because Yaegi cannot handle the error
|
||||
// interface reliably across the interpreter boundary.
|
||||
type ErrorEvent struct {
|
||||
Error string
|
||||
}
|
||||
|
||||
func (e ErrorEvent) Type() EventType { return Error }
|
||||
|
||||
// RetryEvent fires when the LLM provider request is retried after a
|
||||
// transient error.
|
||||
type RetryEvent struct {
|
||||
Attempt int
|
||||
Error string
|
||||
}
|
||||
|
||||
func (e RetryEvent) Type() EventType { return Retry }
|
||||
|
||||
// PrepareStepEvent fires between steps within a multi-step agent turn,
|
||||
// after steering messages are injected and before messages are sent to
|
||||
// the LLM. Handlers can inspect and replace the context window.
|
||||
type PrepareStepEvent struct {
|
||||
// StepNumber is the zero-based step index within the current turn.
|
||||
StepNumber int
|
||||
// Messages is the current context window that will be sent to the LLM.
|
||||
Messages []ContextMessage
|
||||
}
|
||||
|
||||
func (e PrepareStepEvent) Type() EventType { return PrepareStep }
|
||||
|
||||
// PrepareStepResult allows extensions to replace the context window between
|
||||
// steps. Return nil Messages to leave the context unchanged.
|
||||
type PrepareStepResult struct {
|
||||
// Messages replaces the entire context window for this step. If nil,
|
||||
// the original messages are used unchanged. Messages with a non-negative
|
||||
// Index reuse the original message at that position; messages with
|
||||
// Index < 0 are created fresh from Role + Content.
|
||||
Messages []ContextMessage
|
||||
}
|
||||
|
||||
func (PrepareStepResult) isResult() {}
|
||||
|
||||
// LLMUsageEvent fires after each LLM provider call with the per-call token
|
||||
// and cost deltas. Use this for accurate budget tracking, cost dashboards,
|
||||
// and any logic that needs to react between LLM calls within a single agent
|
||||
// turn (rather than only at turn boundaries).
|
||||
//
|
||||
// A single agent turn typically produces multiple LLMUsageEvents (one per
|
||||
// tool-loop iteration). The Model and Provider fields reflect the model used
|
||||
// for that specific call, which may differ from earlier calls if the
|
||||
// extension switched models mid-turn via ctx.SetModel().
|
||||
type LLMUsageEvent struct {
|
||||
// InputTokens is the number of input tokens for this call.
|
||||
InputTokens int
|
||||
// OutputTokens is the number of output tokens generated by this call.
|
||||
OutputTokens int
|
||||
// CacheReadTokens is the number of cache-hit input tokens (provider-specific).
|
||||
CacheReadTokens int
|
||||
// CacheWriteTokens is the number of cache-write tokens.
|
||||
CacheWriteTokens int
|
||||
// Cost is the USD cost of this call computed from the model's per-token
|
||||
// pricing. Zero when pricing is unknown or OAuth credentials are in use.
|
||||
Cost float64
|
||||
// Model is the model identifier used for this call (e.g. "claude-sonnet-4-5-20250929").
|
||||
Model string
|
||||
// Provider is the provider identifier (e.g. "anthropic", "openai").
|
||||
Provider string
|
||||
// RequestID is an optional correlation id for the underlying provider
|
||||
// call. May be empty when the provider does not surface one.
|
||||
RequestID string
|
||||
// StepNumber is the zero-based step index within the current agent turn.
|
||||
StepNumber int
|
||||
// FinishReason mirrors the provider's finish reason for this call
|
||||
// (e.g. "stop", "tool_calls", "length"). May be empty.
|
||||
FinishReason string
|
||||
}
|
||||
|
||||
func (e LLMUsageEvent) Type() EventType { return LLMUsage }
|
||||
|
||||
// ThemeColor is an adaptive color pair with light and dark hex values.
|
||||
// Either field may be empty to inherit from the default theme.
|
||||
type ThemeColor struct {
|
||||
|
||||
@@ -13,6 +13,19 @@ const (
|
||||
// ToolCall fires before a tool executes. Handlers can block execution.
|
||||
ToolCall EventType = "tool_call"
|
||||
|
||||
// ToolCallInputStart fires when the LLM begins generating tool call
|
||||
// arguments. The tool name is known but the full argument JSON is still
|
||||
// being streamed.
|
||||
ToolCallInputStart EventType = "tool_call_input_start"
|
||||
|
||||
// ToolCallInputDelta fires for each streamed fragment of tool call
|
||||
// arguments as they arrive from the LLM.
|
||||
ToolCallInputDelta EventType = "tool_call_input_delta"
|
||||
|
||||
// ToolCallInputEnd fires when tool argument streaming is complete,
|
||||
// before the tool call is parsed and execution begins.
|
||||
ToolCallInputEnd EventType = "tool_call_input_end"
|
||||
|
||||
// ToolExecutionStart fires when a tool begins executing.
|
||||
ToolExecutionStart EventType = "tool_execution_start"
|
||||
|
||||
@@ -83,18 +96,55 @@ const (
|
||||
// SubagentEnd fires when a subagent tool call completes (success
|
||||
// or error). Carries the final response and any error message.
|
||||
SubagentEnd EventType = "subagent_end"
|
||||
|
||||
// StepStart fires when a new LLM call begins within a multi-step
|
||||
// agent turn.
|
||||
StepStart EventType = "step_start"
|
||||
|
||||
// StepFinish fires when a step completes, providing step number,
|
||||
// finish reason, and token usage.
|
||||
StepFinish EventType = "step_finish"
|
||||
|
||||
// ReasoningStart fires when the LLM begins reasoning/thinking.
|
||||
ReasoningStart EventType = "reasoning_start"
|
||||
|
||||
// Warnings fires when the LLM provider returns warnings.
|
||||
Warnings EventType = "warnings"
|
||||
|
||||
// Source fires when the LLM references a source (e.g. web search).
|
||||
Source EventType = "source"
|
||||
|
||||
// Error fires when an agent-level error occurs during streaming.
|
||||
Error EventType = "error"
|
||||
|
||||
// Retry fires when the LLM provider request is retried after a
|
||||
// transient error.
|
||||
Retry EventType = "retry"
|
||||
|
||||
// PrepareStep fires between steps within a multi-step agent turn,
|
||||
// after steering messages are injected and before messages are sent
|
||||
// to the LLM. Handlers can replace the context window for this step.
|
||||
PrepareStep EventType = "prepare_step"
|
||||
|
||||
// LLMUsage fires after each LLM provider call with the token and cost
|
||||
// deltas for that single call. Extensions use it to attribute usage to
|
||||
// specific calls/models and to drive budget enforcement between calls.
|
||||
LLMUsage EventType = "llm_usage"
|
||||
)
|
||||
|
||||
// AllEventTypes returns every supported event type.
|
||||
func AllEventTypes() []EventType {
|
||||
return []EventType{
|
||||
ToolCall, ToolExecutionStart, ToolExecutionEnd, ToolResult,
|
||||
ToolCall, ToolCallInputStart, ToolCallInputDelta, ToolCallInputEnd,
|
||||
ToolExecutionStart, ToolExecutionEnd, ToolResult,
|
||||
Input, BeforeAgentStart, AgentStart, AgentEnd,
|
||||
MessageStart, MessageUpdate, MessageEnd,
|
||||
SessionStart, SessionShutdown,
|
||||
ModelChange, ContextPrepare,
|
||||
BeforeFork, BeforeSessionSwitch, BeforeCompact,
|
||||
SubagentStart, SubagentChunk, SubagentEnd,
|
||||
StepStart, StepFinish, ReasoningStart, Warnings, Source, Error, Retry,
|
||||
PrepareStep, LLMUsage,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import "testing"
|
||||
|
||||
func TestAllEventTypes_Count(t *testing.T) {
|
||||
all := AllEventTypes()
|
||||
if len(all) != 21 {
|
||||
t.Fatalf("expected 21 event types, got %d", len(all))
|
||||
if len(all) != 33 {
|
||||
t.Fatalf("expected 33 event types, got %d", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ func TestEventType_TypeMethod(t *testing.T) {
|
||||
want EventType
|
||||
}{
|
||||
{ToolCallEvent{ToolName: "test"}, ToolCall},
|
||||
{ToolCallInputStartEvent{ToolCallID: "x", ToolName: "test"}, ToolCallInputStart},
|
||||
{ToolCallInputDeltaEvent{ToolCallID: "x", Delta: "{"}, ToolCallInputDelta},
|
||||
{ToolCallInputEndEvent{ToolCallID: "x"}, ToolCallInputEnd},
|
||||
{ToolExecutionStartEvent{ToolName: "test"}, ToolExecutionStart},
|
||||
{ToolExecutionEndEvent{ToolName: "test"}, ToolExecutionEnd},
|
||||
{ToolResultEvent{ToolName: "test"}, ToolResult},
|
||||
|
||||
@@ -450,25 +450,6 @@ func globalGitInstallRoot() string {
|
||||
return filepath.Join(base, "kit", "git")
|
||||
}
|
||||
|
||||
// GetInstalledPackages returns all installed packages from both scopes.
|
||||
func (i *Installer) GetInstalledPackages() ([]ManifestEntry, error) {
|
||||
var all []ManifestEntry
|
||||
|
||||
global, err := i.loadManifest(ScopeGlobal)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading global manifest: %w", err)
|
||||
}
|
||||
all = append(all, global.Packages...)
|
||||
|
||||
project, err := i.loadManifest(ScopeProject)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading project manifest: %w", err)
|
||||
}
|
||||
all = append(all, project.Packages...)
|
||||
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// IsInstalled checks if a package is installed in either scope.
|
||||
// Returns (scope, true) if installed, ("", false) otherwise.
|
||||
func (i *Installer) IsInstalled(source *GitSource) (InstallScope, bool) {
|
||||
|
||||
@@ -245,14 +245,21 @@ func TestManifestEntryIdentity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadAndSaveManifest exercises the live *Installer.loadManifest /
|
||||
// saveManifest round-trip against a temp directory, ensuring an absent
|
||||
// manifest loads as empty and a saved manifest reads back identically.
|
||||
func TestLoadAndSaveManifest(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
installer := &Installer{
|
||||
projectGitRoot: tempDir,
|
||||
globalGitRoot: tempDir,
|
||||
}
|
||||
manifestPath := filepath.Join(tempDir, "packages.json")
|
||||
|
||||
// Test loading non-existent manifest
|
||||
manifest, err := loadManifestFromPath(manifestPath)
|
||||
manifest, err := installer.loadManifest(ScopeGlobal)
|
||||
if err != nil {
|
||||
t.Fatalf("loadManifestFromPath() error = %v", err)
|
||||
t.Fatalf("loadManifest() error = %v", err)
|
||||
}
|
||||
if len(manifest.Packages) != 0 {
|
||||
t.Errorf("Expected empty packages, got %d", len(manifest.Packages))
|
||||
@@ -273,15 +280,20 @@ func TestLoadAndSaveManifest(t *testing.T) {
|
||||
}
|
||||
|
||||
// Save it
|
||||
err = saveManifestToPath(manifest, manifestPath)
|
||||
err = installer.saveManifest(manifest, ScopeGlobal)
|
||||
if err != nil {
|
||||
t.Fatalf("saveManifestToPath() error = %v", err)
|
||||
t.Fatalf("saveManifest() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify it was written to expected path
|
||||
if _, err := os.Stat(manifestPath); err != nil {
|
||||
t.Fatalf("manifest file not created: %v", err)
|
||||
}
|
||||
|
||||
// Load it back
|
||||
loaded, err := loadManifestFromPath(manifestPath)
|
||||
loaded, err := installer.loadManifest(ScopeGlobal)
|
||||
if err != nil {
|
||||
t.Fatalf("loadManifestFromPath() error = %v", err)
|
||||
t.Fatalf("loadManifest() error = %v", err)
|
||||
}
|
||||
if len(loaded.Packages) != 1 {
|
||||
t.Errorf("Expected 1 package, got %d", len(loaded.Packages))
|
||||
@@ -291,21 +303,15 @@ func TestLoadAndSaveManifest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddAndRemoveFromManifest verifies that *Installer.addToManifest
|
||||
// followed by removeFromManifest leaves the manifest in its original
|
||||
// (empty) state, using a temp-directory installer scope.
|
||||
func TestAddAndRemoveFromManifest(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Set up environment for manifest path
|
||||
if err := os.Setenv("XDG_DATA_HOME", tempDir); err != nil {
|
||||
t.Fatalf("Setenv() error = %v", err)
|
||||
installer := &Installer{
|
||||
projectGitRoot: tempDir,
|
||||
globalGitRoot: tempDir,
|
||||
}
|
||||
defer func() {
|
||||
if err := os.Unsetenv("XDG_DATA_HOME"); err != nil {
|
||||
t.Logf("Unsetenv() error = %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// The manifest path when XDG_DATA_HOME is set
|
||||
manifestPath := filepath.Join(tempDir, "kit", "git", "packages.json")
|
||||
|
||||
// Add an entry
|
||||
entry := ManifestEntry{
|
||||
@@ -315,58 +321,51 @@ func TestAddAndRemoveFromManifest(t *testing.T) {
|
||||
Scope: ScopeGlobal,
|
||||
}
|
||||
|
||||
err := addEntryToManifest(entry, ScopeGlobal)
|
||||
if err != nil {
|
||||
t.Fatalf("addEntryToManifest() error = %v", err)
|
||||
if err := installer.addToManifest(entry, ScopeGlobal); err != nil {
|
||||
t.Fatalf("addToManifest() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify it was added
|
||||
manifest, err := loadManifestFromPath(manifestPath)
|
||||
manifest, err := installer.loadManifest(ScopeGlobal)
|
||||
if err != nil {
|
||||
t.Fatalf("loadManifestFromPath() error = %v", err)
|
||||
t.Fatalf("loadManifest() error = %v", err)
|
||||
}
|
||||
if len(manifest.Packages) != 1 {
|
||||
t.Errorf("Expected 1 package, got %d", len(manifest.Packages))
|
||||
}
|
||||
|
||||
// Remove it
|
||||
err = removeEntryFromManifest("github.com/user/repo", ScopeGlobal)
|
||||
if err != nil {
|
||||
t.Fatalf("removeEntryFromManifest() error = %v", err)
|
||||
if err := installer.removeFromManifest("github.com/user/repo", ScopeGlobal); err != nil {
|
||||
t.Fatalf("removeFromManifest() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify it was removed
|
||||
manifest, err = loadManifestFromPath(manifestPath)
|
||||
manifest, err = installer.loadManifest(ScopeGlobal)
|
||||
if err != nil {
|
||||
t.Fatalf("loadManifestFromPath() error = %v", err)
|
||||
t.Fatalf("loadManifest() error = %v", err)
|
||||
}
|
||||
if len(manifest.Packages) != 0 {
|
||||
t.Errorf("Expected 0 packages, got %d", len(manifest.Packages))
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindInManifest writes a manifest file directly to the path
|
||||
// resolved by the package-level manifestPathForScope helper and then
|
||||
// confirms FindInManifest locates the entry by identity (and returns
|
||||
// nil for a non-existent identity).
|
||||
func TestFindInManifest(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
if err := os.Setenv("XDG_DATA_HOME", tempDir); err != nil {
|
||||
t.Fatalf("Setenv() error = %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := os.Unsetenv("XDG_DATA_HOME"); err != nil {
|
||||
t.Logf("Unsetenv() error = %v", err)
|
||||
}
|
||||
}()
|
||||
t.Setenv("XDG_DATA_HOME", tempDir)
|
||||
|
||||
// Add an entry to global manifest
|
||||
entry := ManifestEntry{
|
||||
Source: "git:github.com/user/repo",
|
||||
Host: "github.com",
|
||||
Path: "user/repo",
|
||||
Scope: ScopeGlobal,
|
||||
// Write a manifest entry directly via the package-level path resolver
|
||||
// so FindInManifest (which uses manifestPathForScope) can read it back.
|
||||
manifestPath := manifestPathForScope(ScopeGlobal)
|
||||
if err := os.MkdirAll(filepath.Dir(manifestPath), 0755); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
|
||||
err := addEntryToManifest(entry, ScopeGlobal)
|
||||
if err != nil {
|
||||
t.Fatalf("addEntryToManifest() error = %v", err)
|
||||
data := []byte(`{"packages":[{"source":"git:github.com/user/repo","repo":"","host":"github.com","path":"user/repo","pinned":false,"scope":"global","installed":"0001-01-01T00:00:00Z"}]}`)
|
||||
if err := os.WriteFile(manifestPath, data, 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
// Find it
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package extensions
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRunner_EmitLLMUsage(t *testing.T) {
|
||||
var got LLMUsageEvent
|
||||
var called bool
|
||||
ext := makeHandlerExt("llmusage.go", map[EventType][]HandlerFunc{
|
||||
LLMUsage: {
|
||||
func(e Event, c Context) Result {
|
||||
got = e.(LLMUsageEvent)
|
||||
called = true
|
||||
return nil
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
r := makeRunner(ext)
|
||||
_, err := r.Emit(LLMUsageEvent{
|
||||
InputTokens: 100,
|
||||
OutputTokens: 50,
|
||||
Cost: 0.0012,
|
||||
Model: "claude-sonnet-4-5-20250929",
|
||||
Provider: "anthropic",
|
||||
StepNumber: 2,
|
||||
FinishReason: "tool_calls",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("emit: %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("expected LLMUsage handler to be called")
|
||||
}
|
||||
if got.InputTokens != 100 || got.OutputTokens != 50 {
|
||||
t.Errorf("token fields not propagated: %+v", got)
|
||||
}
|
||||
if got.Cost != 0.0012 {
|
||||
t.Errorf("cost not propagated, got %v", got.Cost)
|
||||
}
|
||||
if got.Model != "claude-sonnet-4-5-20250929" || got.Provider != "anthropic" {
|
||||
t.Errorf("model/provider not propagated: %+v", got)
|
||||
}
|
||||
if got.StepNumber != 2 || got.FinishReason != "tool_calls" {
|
||||
t.Errorf("step/finish reason not propagated: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_LLMUsageRegisteredViaTestAPI(t *testing.T) {
|
||||
// Verify NewTestAPI wires up onLLMUsage so the extension can call
|
||||
// api.OnLLMUsage during Init.
|
||||
ext := &LoadedExtension{Handlers: make(map[EventType][]HandlerFunc)}
|
||||
api := NewTestAPI(ext)
|
||||
|
||||
var calls int
|
||||
api.OnLLMUsage(func(e LLMUsageEvent, c Context) {
|
||||
calls++
|
||||
})
|
||||
|
||||
if len(ext.Handlers[LLMUsage]) != 1 {
|
||||
t.Fatalf("expected 1 LLMUsage handler registered, got %d", len(ext.Handlers[LLMUsage]))
|
||||
}
|
||||
|
||||
r := makeRunner(*ext)
|
||||
_, _ = r.Emit(LLMUsageEvent{InputTokens: 1})
|
||||
if calls != 1 {
|
||||
t.Errorf("expected handler called once, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentEndEvent_EnrichedFields(t *testing.T) {
|
||||
// Verify the enriched event carries through Emit without mangling.
|
||||
var got AgentEndEvent
|
||||
ext := makeHandlerExt("end.go", map[EventType][]HandlerFunc{
|
||||
AgentEnd: {
|
||||
func(e Event, c Context) Result {
|
||||
got = e.(AgentEndEvent)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
})
|
||||
r := makeRunner(ext)
|
||||
_, err := r.Emit(AgentEndEvent{
|
||||
Response: "done",
|
||||
StopReason: "completed",
|
||||
ToolCallCount: 3,
|
||||
ToolNames: []string{"bash", "read", "bash"},
|
||||
LLMCallCount: 4,
|
||||
InputTokensDelta: 1500,
|
||||
OutputTokensDelta: 400,
|
||||
CacheReadTokensDelta: 200,
|
||||
CacheWriteTokensDelta: 100,
|
||||
CostDelta: 0.0123,
|
||||
DurationMs: 2500,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("emit: %v", err)
|
||||
}
|
||||
if got.ToolCallCount != 3 {
|
||||
t.Errorf("ToolCallCount: got %d want 3", got.ToolCallCount)
|
||||
}
|
||||
if len(got.ToolNames) != 3 || got.ToolNames[0] != "bash" || got.ToolNames[2] != "bash" {
|
||||
t.Errorf("ToolNames: %v", got.ToolNames)
|
||||
}
|
||||
if got.LLMCallCount != 4 {
|
||||
t.Errorf("LLMCallCount: got %d want 4", got.LLMCallCount)
|
||||
}
|
||||
if got.InputTokensDelta != 1500 || got.OutputTokensDelta != 400 {
|
||||
t.Errorf("token deltas: %+v", got)
|
||||
}
|
||||
if got.CacheReadTokensDelta != 200 || got.CacheWriteTokensDelta != 100 {
|
||||
t.Errorf("cache deltas: %+v", got)
|
||||
}
|
||||
if got.CostDelta != 0.0123 {
|
||||
t.Errorf("CostDelta: got %v", got.CostDelta)
|
||||
}
|
||||
if got.DurationMs != 2500 {
|
||||
t.Errorf("DurationMs: got %d", got.DurationMs)
|
||||
}
|
||||
}
|
||||
@@ -429,6 +429,24 @@ func loadSingleExtension(path string) (*LoadedExtension, error) {
|
||||
return *r
|
||||
})
|
||||
},
|
||||
onToolCallInputStart: func(h func(ToolCallInputStartEvent, Context)) {
|
||||
reg(ToolCallInputStart, func(e Event, c Context) Result {
|
||||
h(e.(ToolCallInputStartEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onToolCallInputDelta: func(h func(ToolCallInputDeltaEvent, Context)) {
|
||||
reg(ToolCallInputDelta, func(e Event, c Context) Result {
|
||||
h(e.(ToolCallInputDeltaEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onToolCallInputEnd: func(h func(ToolCallInputEndEvent, Context)) {
|
||||
reg(ToolCallInputEnd, func(e Event, c Context) Result {
|
||||
h(e.(ToolCallInputEndEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onToolExecStart: func(h func(ToolExecutionStartEvent, Context)) {
|
||||
reg(ToolExecutionStart, func(e Event, c Context) Result {
|
||||
h(e.(ToolExecutionStartEvent), c)
|
||||
@@ -600,6 +618,63 @@ func loadSingleExtension(path string) (*LoadedExtension, error) {
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onStepStart: func(h func(StepStartEvent, Context)) {
|
||||
reg(StepStart, func(e Event, c Context) Result {
|
||||
h(e.(StepStartEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onStepFinish: func(h func(StepFinishEvent, Context)) {
|
||||
reg(StepFinish, func(e Event, c Context) Result {
|
||||
h(e.(StepFinishEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onReasoningStart: func(h func(ReasoningStartEvent, Context)) {
|
||||
reg(ReasoningStart, func(e Event, c Context) Result {
|
||||
h(e.(ReasoningStartEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onWarnings: func(h func(WarningsEvent, Context)) {
|
||||
reg(Warnings, func(e Event, c Context) Result {
|
||||
h(e.(WarningsEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onSource: func(h func(SourceEvent, Context)) {
|
||||
reg(Source, func(e Event, c Context) Result {
|
||||
h(e.(SourceEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onError: func(h func(ErrorEvent, Context)) {
|
||||
reg(Error, func(e Event, c Context) Result {
|
||||
h(e.(ErrorEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onRetry: func(h func(RetryEvent, Context)) {
|
||||
reg(Retry, func(e Event, c Context) Result {
|
||||
h(e.(RetryEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onPrepareStep: func(h func(PrepareStepEvent, Context) *PrepareStepResult) {
|
||||
reg(PrepareStep, func(e Event, c Context) Result {
|
||||
r := h(e.(PrepareStepEvent), c)
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return *r
|
||||
})
|
||||
},
|
||||
onLLMUsage: func(h func(LLMUsageEvent, Context)) {
|
||||
reg(LLMUsage, func(e Event, c Context) Result {
|
||||
h(e.(LLMUsageEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// Call Init — the extension registers its handlers, tools, commands.
|
||||
|
||||
@@ -72,30 +72,6 @@ func loadManifestFromPath(path string) (*Manifest, error) {
|
||||
return &manifest, nil
|
||||
}
|
||||
|
||||
// saveManifestToScope saves the manifest to the given scope.
|
||||
func saveManifestToScope(manifest *Manifest, scope InstallScope) error {
|
||||
path := manifestPathForScope(scope)
|
||||
return saveManifestToPath(manifest, path)
|
||||
}
|
||||
|
||||
// saveManifestToPath saves a manifest to a specific file path.
|
||||
func saveManifestToPath(manifest *Manifest, path string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return fmt.Errorf("creating manifest directory: %w", err)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encoding manifest: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
return fmt.Errorf("writing manifest: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// manifestPathForScope returns the manifest file path for a scope.
|
||||
func manifestPathForScope(scope InstallScope) string {
|
||||
if scope == ScopeProject {
|
||||
@@ -113,55 +89,6 @@ func manifestPathForScope(scope InstallScope) string {
|
||||
return filepath.Join(base, "kit", "git", "packages.json")
|
||||
}
|
||||
|
||||
// GetGlobalManifest returns the global manifest.
|
||||
func GetGlobalManifest() (*Manifest, error) {
|
||||
return loadManifestFromScope(ScopeGlobal)
|
||||
}
|
||||
|
||||
// GetProjectManifest returns the project manifest.
|
||||
func GetProjectManifest() (*Manifest, error) {
|
||||
return loadManifestFromScope(ScopeProject)
|
||||
}
|
||||
|
||||
// addEntryToManifest adds or replaces an entry in the manifest for a scope.
|
||||
func addEntryToManifest(entry ManifestEntry, scope InstallScope) error {
|
||||
manifest, err := loadManifestFromScope(scope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove any existing entry with same identity
|
||||
identity := entry.Identity()
|
||||
filtered := make([]ManifestEntry, 0, len(manifest.Packages))
|
||||
for _, p := range manifest.Packages {
|
||||
if p.Identity() != identity {
|
||||
filtered = append(filtered, p)
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, entry)
|
||||
manifest.Packages = filtered
|
||||
|
||||
return saveManifestToScope(manifest, scope)
|
||||
}
|
||||
|
||||
// removeEntryFromManifest removes an entry by identity from the manifest for a scope.
|
||||
func removeEntryFromManifest(identity string, scope InstallScope) error {
|
||||
manifest, err := loadManifestFromScope(scope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
filtered := make([]ManifestEntry, 0, len(manifest.Packages))
|
||||
for _, p := range manifest.Packages {
|
||||
if p.Identity() != identity {
|
||||
filtered = append(filtered, p)
|
||||
}
|
||||
}
|
||||
manifest.Packages = filtered
|
||||
|
||||
return saveManifestToScope(manifest, scope)
|
||||
}
|
||||
|
||||
// FindInManifest finds an entry by identity in either global or project manifest.
|
||||
// Returns the entry and its scope, or nil if not found.
|
||||
func FindInManifest(identity string) (*ManifestEntry, InstallScope, error) {
|
||||
|
||||
@@ -1,21 +1,96 @@
|
||||
package extensions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// reentrantMu — a per-extension mutex that allows the same goroutine to
|
||||
// re-enter (e.g. handler → ctx.EmitCustomEvent → handler in same extension).
|
||||
// Different goroutines are serialized, preventing concurrent state mutation.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type reentrantMu struct {
|
||||
mu sync.Mutex
|
||||
cond *sync.Cond
|
||||
owner int64 // goroutine ID that holds the lock, or 0
|
||||
depth int // re-entrancy depth
|
||||
}
|
||||
|
||||
// initReentrantMu initializes the reentrant mutex in-place. Must be called
|
||||
// after the struct is at its final memory location (not before copying).
|
||||
func (r *reentrantMu) init() {
|
||||
r.cond = sync.NewCond(&r.mu)
|
||||
}
|
||||
|
||||
// lock acquires the mutex. If the calling goroutine already holds it, the
|
||||
// call succeeds immediately (re-entrant). Every call to lock must be paired
|
||||
// with a call to unlock.
|
||||
func (r *reentrantMu) lock() {
|
||||
gid := goroutineID()
|
||||
r.mu.Lock()
|
||||
if r.owner == gid {
|
||||
// Re-entrant: same goroutine already holds the lock.
|
||||
r.depth++
|
||||
r.mu.Unlock()
|
||||
return
|
||||
}
|
||||
// Wait for the current owner to release.
|
||||
for r.owner != 0 {
|
||||
r.cond.Wait() // releases mu, blocks, re-acquires mu on wake
|
||||
}
|
||||
r.owner = gid
|
||||
r.depth = 1
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// unlock releases the mutex (or decrements re-entrancy depth).
|
||||
func (r *reentrantMu) unlock() {
|
||||
r.mu.Lock()
|
||||
r.depth--
|
||||
if r.depth == 0 {
|
||||
r.owner = 0
|
||||
r.cond.Signal()
|
||||
}
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// goroutineID extracts the current goroutine's ID from runtime.Stack output.
|
||||
// This is a well-known technique used by Go testing infrastructure.
|
||||
func goroutineID() int64 {
|
||||
var buf [64]byte
|
||||
n := runtime.Stack(buf[:], false)
|
||||
// Stack output starts with "goroutine NNN ["
|
||||
s := buf[:n]
|
||||
s = s[len("goroutine "):]
|
||||
s = s[:bytes.IndexByte(s, ' ')]
|
||||
id, _ := strconv.ParseInt(string(s), 10, 64)
|
||||
return id
|
||||
}
|
||||
|
||||
// Runner manages loaded extensions and dispatches events to their handlers
|
||||
// sequentially. Handlers execute in extension
|
||||
// load order; for cancellable events the first blocking result wins.
|
||||
//
|
||||
// Each extension has a dedicated reentrant mutex so that handlers for the
|
||||
// same extension are serialized (preventing data races on shared package-level
|
||||
// state), while handlers for different extensions may execute concurrently.
|
||||
type Runner struct {
|
||||
extensions []LoadedExtension
|
||||
extMu []reentrantMu // per-extension reentrant mutex, indexed by extension position
|
||||
ctx Context
|
||||
widgets map[string]WidgetConfig // keyed by widget ID
|
||||
statusEntries map[string]StatusBarEntry // keyed by status key
|
||||
@@ -26,9 +101,24 @@ type Runner struct {
|
||||
disabledTools map[string]bool // nil = all tools enabled
|
||||
customEventSubs map[string][]func(string) // inter-extension event bus
|
||||
optionOverrides map[string]string // runtime option overrides
|
||||
configStore *viper.Viper // per-instance config store (nil = global)
|
||||
state map[string]string // session-scoped extension state (last-write-wins)
|
||||
stateMu sync.RWMutex // guards state independently of mu
|
||||
saverMu sync.Mutex // serializes stateSaver invocations so atomic-rename writes don't interleave
|
||||
stateSaver func() // optional persistence hook invoked after each state mutation
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// SetConfigStore sets the per-instance configuration store used by GetOption
|
||||
// to resolve "options.<name>" config values. When unset (nil), GetOption falls
|
||||
// back to the process-global viper store. Threading a per-Kit store keeps
|
||||
// extension option resolution isolated between Kit instances.
|
||||
func (r *Runner) SetConfigStore(v *viper.Viper) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.configStore = v
|
||||
}
|
||||
|
||||
// ShortcutEntry pairs a shortcut definition with its handler.
|
||||
type ShortcutEntry struct {
|
||||
Def ShortcutDef
|
||||
@@ -52,7 +142,11 @@ type LoadedExtension struct {
|
||||
|
||||
// NewRunner creates a Runner from a set of loaded extensions.
|
||||
func NewRunner(exts []LoadedExtension) *Runner {
|
||||
return &Runner{extensions: exts}
|
||||
mus := make([]reentrantMu, len(exts))
|
||||
for i := range mus {
|
||||
mus[i].init()
|
||||
}
|
||||
return &Runner{extensions: exts, extMu: mus}
|
||||
}
|
||||
|
||||
// SetContext updates the runtime context (session ID, model, etc.) that is
|
||||
@@ -177,6 +271,18 @@ func normalizeContext(ctx Context) Context {
|
||||
if ctx.GetEntries == nil {
|
||||
ctx.GetEntries = func(string) []ExtensionEntry { return nil }
|
||||
}
|
||||
if ctx.SetState == nil {
|
||||
ctx.SetState = func(string, string) {}
|
||||
}
|
||||
if ctx.GetState == nil {
|
||||
ctx.GetState = func(string) (string, bool) { return "", false }
|
||||
}
|
||||
if ctx.DeleteState == nil {
|
||||
ctx.DeleteState = func(string) {}
|
||||
}
|
||||
if ctx.ListState == nil {
|
||||
ctx.ListState = func() []string { return nil }
|
||||
}
|
||||
if ctx.GetOption == nil {
|
||||
ctx.GetOption = func(string) string { return "" }
|
||||
}
|
||||
@@ -367,6 +473,11 @@ func (r *Runner) Emit(event Event) (Result, error) {
|
||||
for i := range r.extensions {
|
||||
ext := &r.extensions[i]
|
||||
handlers := ext.Handlers[event.Type()]
|
||||
if len(handlers) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
r.extMu[i].lock()
|
||||
for _, handler := range handlers {
|
||||
result, err := safeCall(handler, event, ctx)
|
||||
if err != nil {
|
||||
@@ -379,6 +490,7 @@ func (r *Runner) Emit(event Event) (Result, error) {
|
||||
|
||||
// Check for blocking/short-circuit results.
|
||||
if isBlocking(result) {
|
||||
r.extMu[i].unlock()
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -386,6 +498,7 @@ func (r *Runner) Emit(event Event) (Result, error) {
|
||||
// the caller is responsible for applying the modifications.
|
||||
accumulated = result
|
||||
}
|
||||
r.extMu[i].unlock()
|
||||
}
|
||||
return accumulated, nil
|
||||
}
|
||||
@@ -651,6 +764,168 @@ func (r *Runner) GetMessageRenderer(name string) *MessageRendererConfig {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extension state store (session-scoped, last-write-wins)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SetState records a key-value pair in the runner's session-scoped extension
|
||||
// state store. The store is in-memory; callers wire SetStateSaver to persist
|
||||
// changes to a sidecar file. Thread-safe.
|
||||
//
|
||||
// When a saver is installed, concurrent SetState/DeleteState invocations are
|
||||
// serialized through saverMu so that overlapping snapshot-and-rename writes
|
||||
// cannot interleave (which would otherwise race on the shared tmp file and
|
||||
// risk persisting an older snapshot after a newer one).
|
||||
func (r *Runner) SetState(key, value string) {
|
||||
r.stateMu.Lock()
|
||||
if r.state == nil {
|
||||
r.state = make(map[string]string)
|
||||
}
|
||||
r.state[key] = value
|
||||
saver := r.stateSaver
|
||||
r.stateMu.Unlock()
|
||||
r.runSaver(saver)
|
||||
}
|
||||
|
||||
// GetState returns the value previously stored via SetState, plus a bool
|
||||
// indicating whether the key was present. Thread-safe.
|
||||
func (r *Runner) GetState(key string) (string, bool) {
|
||||
r.stateMu.RLock()
|
||||
defer r.stateMu.RUnlock()
|
||||
v, ok := r.state[key]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// DeleteState removes a key from the state store. No-op if the key is
|
||||
// missing. Thread-safe. Saver invocations are serialized via saverMu — see
|
||||
// SetState for the rationale.
|
||||
func (r *Runner) DeleteState(key string) {
|
||||
r.stateMu.Lock()
|
||||
_, existed := r.state[key]
|
||||
if existed {
|
||||
delete(r.state, key)
|
||||
}
|
||||
saver := r.stateSaver
|
||||
r.stateMu.Unlock()
|
||||
if !existed {
|
||||
return
|
||||
}
|
||||
r.runSaver(saver)
|
||||
}
|
||||
|
||||
// runSaver invokes the optional persistence callback under saverMu so
|
||||
// concurrent SetState/DeleteState writers cannot race on the shared tmp
|
||||
// file used by SaveStateToFile's atomic rename. The deferred Unlock
|
||||
// guarantees saverMu is released even if the saver panics.
|
||||
func (r *Runner) runSaver(saver func()) {
|
||||
if saver == nil {
|
||||
return
|
||||
}
|
||||
r.saverMu.Lock()
|
||||
defer r.saverMu.Unlock()
|
||||
saver()
|
||||
}
|
||||
|
||||
// ListState returns all keys currently in the state store, in unspecified
|
||||
// order. Thread-safe.
|
||||
func (r *Runner) ListState() []string {
|
||||
r.stateMu.RLock()
|
||||
defer r.stateMu.RUnlock()
|
||||
if len(r.state) == 0 {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(r.state))
|
||||
for k := range r.state {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// SetStateSaver installs an optional persistence hook invoked after each
|
||||
// mutation to the state store (SetState / DeleteState / LoadStateFromFile).
|
||||
// Pass nil to disable persistence. Thread-safe.
|
||||
func (r *Runner) SetStateSaver(saver func()) {
|
||||
r.stateMu.Lock()
|
||||
defer r.stateMu.Unlock()
|
||||
r.stateSaver = saver
|
||||
}
|
||||
|
||||
// SnapshotState returns a copy of the current state store as a
|
||||
// fresh map. Useful for persisting to disk without holding the lock.
|
||||
// Thread-safe.
|
||||
func (r *Runner) SnapshotState() map[string]string {
|
||||
r.stateMu.RLock()
|
||||
defer r.stateMu.RUnlock()
|
||||
if len(r.state) == 0 {
|
||||
return nil
|
||||
}
|
||||
copyMap := make(map[string]string, len(r.state))
|
||||
maps.Copy(copyMap, r.state)
|
||||
return copyMap
|
||||
}
|
||||
|
||||
// LoadStateFromFile reads a JSON map from path and replaces the in-memory
|
||||
// state store with its contents. Missing or empty files are treated as
|
||||
// "no prior state": the in-memory store is replaced with an empty map so
|
||||
// callers can safely switch sessions without leaking keys from a prior
|
||||
// session into a new one. Malformed JSON returns the parse error without
|
||||
// touching the existing store. Thread-safe.
|
||||
func (r *Runner) LoadStateFromFile(path string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
r.stateMu.Lock()
|
||||
r.state = map[string]string{}
|
||||
r.stateMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("reading extension state: %w", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
r.stateMu.Lock()
|
||||
r.state = map[string]string{}
|
||||
r.stateMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
var loaded map[string]string
|
||||
if err := json.Unmarshal(data, &loaded); err != nil {
|
||||
return fmt.Errorf("parsing extension state: %w", err)
|
||||
}
|
||||
r.stateMu.Lock()
|
||||
r.state = loaded
|
||||
r.stateMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveStateToFile writes the current state store to path as JSON, creating
|
||||
// parent directories as needed. An empty store writes an empty object so
|
||||
// that consumers can distinguish "loaded but empty" from "never saved".
|
||||
// Writes are atomic via a tmp-file-and-rename sequence. Thread-safe.
|
||||
func (r *Runner) SaveStateToFile(path string) error {
|
||||
snap := r.SnapshotState()
|
||||
if snap == nil {
|
||||
snap = map[string]string{}
|
||||
}
|
||||
data, err := json.MarshalIndent(snap, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshalling extension state: %w", err)
|
||||
}
|
||||
if dir := filepath.Dir(path); dir != "." && dir != "" {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("creating state directory: %w", err)
|
||||
}
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return fmt.Errorf("writing extension state: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("renaming extension state: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hot-reload
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -674,7 +949,9 @@ func (r *Runner) Reload(exts []LoadedExtension) {
|
||||
r.uiVisibility = nil
|
||||
r.disabledTools = nil
|
||||
r.customEventSubs = nil
|
||||
// optionOverrides are intentionally preserved.
|
||||
// optionOverrides and state are intentionally preserved across reloads:
|
||||
// they represent user/session intent (not extension code) and would be
|
||||
// surprising to lose on a hot-reload.
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -712,11 +989,17 @@ func (r *Runner) EmitCustomEvent(name, data string) {
|
||||
|
||||
// Extension-registered handlers first (in load order).
|
||||
for i := range r.extensions {
|
||||
for _, h := range r.extensions[i].CustomEventHandlers[name] {
|
||||
extHandlers := r.extensions[i].CustomEventHandlers[name]
|
||||
if len(extHandlers) == 0 {
|
||||
continue
|
||||
}
|
||||
r.extMu[i].lock()
|
||||
for _, h := range extHandlers {
|
||||
safeInvoke(h)
|
||||
}
|
||||
r.extMu[i].unlock()
|
||||
}
|
||||
// Then dynamic subscriptions.
|
||||
// Then dynamic subscriptions (not extension-scoped, no per-ext lock).
|
||||
for _, h := range dynamicHandlers {
|
||||
safeInvoke(h)
|
||||
}
|
||||
@@ -783,7 +1066,13 @@ func (r *Runner) GetOption(name string) string {
|
||||
|
||||
// 3. Viper config: options.<name>
|
||||
configKey := "options." + name
|
||||
if v := viper.GetString(configKey); v != "" {
|
||||
r.mu.RLock()
|
||||
store := r.configStore
|
||||
r.mu.RUnlock()
|
||||
if store == nil {
|
||||
store = viper.GetViper()
|
||||
}
|
||||
if v := store.GetString(configKey); v != "" {
|
||||
return v
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package extensions
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -571,3 +572,142 @@ func TestRunner_ContextPrintNilSafe(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_ConcurrentEmitSameExtension(t *testing.T) {
|
||||
// Verify that concurrent Emit calls for the same extension are serialized
|
||||
// and don't cause data races on shared handler state.
|
||||
var counter int
|
||||
ext := makeHandlerExt("shared-state.go", map[EventType][]HandlerFunc{
|
||||
SubagentStart: {
|
||||
func(e Event, c Context) Result {
|
||||
// Read-modify-write: racy without serialization.
|
||||
v := counter
|
||||
counter = v + 1
|
||||
return nil
|
||||
},
|
||||
},
|
||||
SubagentChunk: {
|
||||
func(e Event, c Context) Result {
|
||||
v := counter
|
||||
counter = v + 1
|
||||
return nil
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
r := makeRunner(ext)
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 20
|
||||
const iterations = 50
|
||||
wg.Add(goroutines)
|
||||
for range goroutines {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for range iterations {
|
||||
_, _ = r.Emit(SubagentStartEvent{ToolCallID: "x"})
|
||||
_, _ = r.Emit(SubagentChunkEvent{ToolCallID: "x"})
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if counter != goroutines*iterations*2 {
|
||||
t.Errorf("expected counter=%d, got %d (race detected)", goroutines*iterations*2, counter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_ConcurrentEmitDifferentExtensions(t *testing.T) {
|
||||
// Two extensions with independent state should not block each other
|
||||
// and should both run correctly under concurrent Emit calls.
|
||||
var counter1, counter2 int
|
||||
ext1 := makeHandlerExt("ext1.go", map[EventType][]HandlerFunc{
|
||||
SubagentStart: {
|
||||
func(e Event, c Context) Result {
|
||||
v := counter1
|
||||
counter1 = v + 1
|
||||
return nil
|
||||
},
|
||||
},
|
||||
})
|
||||
ext2 := makeHandlerExt("ext2.go", map[EventType][]HandlerFunc{
|
||||
SubagentStart: {
|
||||
func(e Event, c Context) Result {
|
||||
v := counter2
|
||||
counter2 = v + 1
|
||||
return nil
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
r := makeRunner(ext1, ext2)
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 20
|
||||
const iterations = 50
|
||||
wg.Add(goroutines)
|
||||
for range goroutines {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for range iterations {
|
||||
_, _ = r.Emit(SubagentStartEvent{ToolCallID: "x"})
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
expected := goroutines * iterations
|
||||
if counter1 != expected {
|
||||
t.Errorf("ext1 counter: expected %d, got %d", expected, counter1)
|
||||
}
|
||||
if counter2 != expected {
|
||||
t.Errorf("ext2 counter: expected %d, got %d", expected, counter2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_ReentrantEmitCustomEvent(t *testing.T) {
|
||||
// Verify that a handler can call EmitCustomEvent (which dispatches to
|
||||
// the same extension's custom event handlers) without deadlocking.
|
||||
var order []string
|
||||
ext := LoadedExtension{
|
||||
Path: "reentrant.go",
|
||||
Handlers: map[EventType][]HandlerFunc{
|
||||
SessionStart: {
|
||||
func(e Event, c Context) Result {
|
||||
order = append(order, "session_start")
|
||||
// This triggers EmitCustomEvent for the same extension
|
||||
// via a direct runner call (simulating ctx.EmitCustomEvent).
|
||||
return nil
|
||||
},
|
||||
},
|
||||
},
|
||||
CustomEventHandlers: map[string][]func(string){
|
||||
"test-event": {
|
||||
func(data string) {
|
||||
order = append(order, "custom:"+data)
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
r := makeRunner(ext)
|
||||
|
||||
// Wire up the handler to call EmitCustomEvent re-entrantly.
|
||||
ext.Handlers[SessionStart] = []HandlerFunc{
|
||||
func(e Event, c Context) Result {
|
||||
order = append(order, "session_start")
|
||||
r.EmitCustomEvent("test-event", "hello")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
r.extensions[0] = ext
|
||||
// Rebuild mutexes after modifying extensions slice.
|
||||
r.extMu = make([]reentrantMu, len(r.extensions))
|
||||
for i := range r.extMu {
|
||||
r.extMu[i].init()
|
||||
}
|
||||
|
||||
_, err := r.Emit(SessionStartEvent{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(order) != 2 || order[0] != "session_start" || order[1] != "custom:hello" {
|
||||
t.Errorf("expected [session_start, custom:hello], got %v", order)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
package extensions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRunner_State_BasicSetGetDelete(t *testing.T) {
|
||||
r := NewRunner(nil)
|
||||
|
||||
if _, ok := r.GetState("missing"); ok {
|
||||
t.Fatal("expected GetState to return ok=false for missing key")
|
||||
}
|
||||
|
||||
r.SetState("a", "1")
|
||||
r.SetState("b", "2")
|
||||
r.SetState("a", "3") // last-write-wins
|
||||
|
||||
if v, ok := r.GetState("a"); !ok || v != "3" {
|
||||
t.Errorf("expected GetState(a)=(3,true), got (%q,%v)", v, ok)
|
||||
}
|
||||
if v, ok := r.GetState("b"); !ok || v != "2" {
|
||||
t.Errorf("expected GetState(b)=(2,true), got (%q,%v)", v, ok)
|
||||
}
|
||||
|
||||
keys := r.ListState()
|
||||
if len(keys) != 2 {
|
||||
t.Errorf("expected 2 keys, got %d (%v)", len(keys), keys)
|
||||
}
|
||||
|
||||
r.DeleteState("a")
|
||||
if _, ok := r.GetState("a"); ok {
|
||||
t.Error("expected key a to be gone after DeleteState")
|
||||
}
|
||||
if len(r.ListState()) != 1 {
|
||||
t.Errorf("expected 1 key after delete, got %v", r.ListState())
|
||||
}
|
||||
|
||||
// Deleting missing key is a no-op.
|
||||
r.DeleteState("never-there")
|
||||
}
|
||||
|
||||
func TestRunner_State_SaverFires(t *testing.T) {
|
||||
r := NewRunner(nil)
|
||||
var calls int
|
||||
var mu sync.Mutex
|
||||
r.SetStateSaver(func() {
|
||||
mu.Lock()
|
||||
calls++
|
||||
mu.Unlock()
|
||||
})
|
||||
|
||||
r.SetState("a", "1")
|
||||
r.SetState("a", "2")
|
||||
r.DeleteState("a")
|
||||
r.DeleteState("a") // missing → no save
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if calls != 3 {
|
||||
t.Errorf("expected saver to fire 3 times (2 sets + 1 delete), got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_State_SaveAndLoadRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "ext-state.json")
|
||||
|
||||
r1 := NewRunner(nil)
|
||||
r1.SetState("k1", "v1")
|
||||
r1.SetState("k2", `{"json":"value"}`)
|
||||
if err := r1.SaveStateToFile(path); err != nil {
|
||||
t.Fatalf("SaveStateToFile: %v", err)
|
||||
}
|
||||
|
||||
// Verify file contains JSON map.
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reading saved file: %v", err)
|
||||
}
|
||||
var parsed map[string]string
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
t.Fatalf("unmarshalling: %v", err)
|
||||
}
|
||||
if parsed["k1"] != "v1" || parsed["k2"] != `{"json":"value"}` {
|
||||
t.Errorf("unexpected file contents: %v", parsed)
|
||||
}
|
||||
|
||||
r2 := NewRunner(nil)
|
||||
if err := r2.LoadStateFromFile(path); err != nil {
|
||||
t.Fatalf("LoadStateFromFile: %v", err)
|
||||
}
|
||||
if v, ok := r2.GetState("k1"); !ok || v != "v1" {
|
||||
t.Errorf("expected k1=v1 after load, got (%q,%v)", v, ok)
|
||||
}
|
||||
if v, ok := r2.GetState("k2"); !ok || v != `{"json":"value"}` {
|
||||
t.Errorf("expected k2 to round-trip, got %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_State_LoadMissingFileClearsState(t *testing.T) {
|
||||
// LoadStateFromFile is documented to "replace the in-memory state store
|
||||
// with its contents"; for a missing file that means clearing the store.
|
||||
// This is what makes session-switching safe: a new session that has not
|
||||
// yet written a sidecar must not inherit keys from a prior session.
|
||||
r := NewRunner(nil)
|
||||
r.SetState("a", "1")
|
||||
if err := r.LoadStateFromFile(filepath.Join(t.TempDir(), "does-not-exist.json")); err != nil {
|
||||
t.Errorf("expected nil error for missing file, got %v", err)
|
||||
}
|
||||
if _, ok := r.GetState("a"); ok {
|
||||
t.Error("expected pre-existing state to be cleared when target file is missing")
|
||||
}
|
||||
if keys := r.ListState(); keys != nil {
|
||||
t.Errorf("expected ListState() to be nil after clearing, got %v", keys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_State_LoadEmptyFileClearsState(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "empty.json")
|
||||
if err := os.WriteFile(path, nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := NewRunner(nil)
|
||||
r.SetState("a", "1")
|
||||
if err := r.LoadStateFromFile(path); err != nil {
|
||||
t.Errorf("expected nil error for empty file, got %v", err)
|
||||
}
|
||||
if _, ok := r.GetState("a"); ok {
|
||||
t.Error("expected pre-existing state to be cleared when target file is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_State_LoadMalformedFileError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "bad.json")
|
||||
if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := NewRunner(nil)
|
||||
if err := r.LoadStateFromFile(path); err == nil {
|
||||
t.Error("expected error loading malformed JSON, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_State_PersistenceViaSaver(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "ext-state.json")
|
||||
|
||||
r := NewRunner(nil)
|
||||
r.SetStateSaver(func() {
|
||||
_ = r.SaveStateToFile(path)
|
||||
})
|
||||
r.SetState("hello", "world")
|
||||
|
||||
// File should exist with the value already.
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reading saved file: %v", err)
|
||||
}
|
||||
var parsed map[string]string
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
t.Fatalf("unmarshalling: %v", err)
|
||||
}
|
||||
if parsed["hello"] != "world" {
|
||||
t.Errorf("expected file to contain hello=world, got %v", parsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_State_ConcurrentSet(t *testing.T) {
|
||||
r := NewRunner(nil)
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 16
|
||||
const iterations = 100
|
||||
wg.Add(goroutines)
|
||||
for range goroutines {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for range iterations {
|
||||
r.SetState("k", "v")
|
||||
_, _ = r.GetState("k")
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if v, ok := r.GetState("k"); !ok || v != "v" {
|
||||
t.Errorf("expected k=v after concurrent writes, got (%q,%v)", v, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_State_ContextNoOpsWhenUnset(t *testing.T) {
|
||||
// Verify normalizeContext installs safe no-ops for SetState/GetState/etc.
|
||||
// when not provided by the caller.
|
||||
ext := makeHandlerExt("state.go", map[EventType][]HandlerFunc{
|
||||
SessionStart: {
|
||||
func(e Event, c Context) Result {
|
||||
// All four state functions should be non-nil and safe to call.
|
||||
c.SetState("a", "b")
|
||||
if v, ok := c.GetState("a"); ok || v != "" {
|
||||
t.Errorf("no-op GetState should return (\"\", false); got (%q,%v)", v, ok)
|
||||
}
|
||||
c.DeleteState("a")
|
||||
if keys := c.ListState(); keys != nil {
|
||||
t.Errorf("no-op ListState should return nil; got %v", keys)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
})
|
||||
r := makeRunner(ext)
|
||||
// SetContext with empty Context to exercise normalizeContext defaults.
|
||||
r.SetContext(Context{})
|
||||
_, err := r.Emit(SessionStartEvent{})
|
||||
if err != nil {
|
||||
t.Fatalf("emit: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_State_SaverPanicReleasesSaverMu(t *testing.T) {
|
||||
// If the saver callback panics (e.g. disk full mid-write), runSaver
|
||||
// must still release saverMu so subsequent SetState/DeleteState calls
|
||||
// can make progress. Without `defer Unlock()` the lock would be
|
||||
// permanently held and the next write would deadlock.
|
||||
r := NewRunner(nil)
|
||||
var calls int
|
||||
r.SetStateSaver(func() {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
panic("simulated disk-write failure")
|
||||
}
|
||||
})
|
||||
|
||||
// First call panics. Recover, then verify a follow-up call still works
|
||||
// without blocking (proving saverMu was released).
|
||||
func() {
|
||||
defer func() {
|
||||
if rec := recover(); rec == nil {
|
||||
t.Fatal("expected panic from first saver invocation")
|
||||
}
|
||||
}()
|
||||
r.SetState("a", "1")
|
||||
}()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
r.SetState("b", "2") // would deadlock if saverMu were still held
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("SetState after saver panic blocked — saverMu was not released")
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Errorf("expected saver to fire twice (panic + recovery write), got %d", calls)
|
||||
}
|
||||
}
|
||||
@@ -2,22 +2,15 @@
|
||||
package extensions
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subagent types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SubagentConfig configures a subagent spawn.
|
||||
type SubagentConfig struct {
|
||||
// Prompt is the task/instruction for the subagent (required).
|
||||
@@ -157,221 +150,3 @@ func (h *SubagentHandle) Wait() SubagentResult {
|
||||
func (h *SubagentHandle) Done() <-chan struct{} {
|
||||
return h.done
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// subagentJSONOutput matches the JSON envelope produced by `kit --json`.
|
||||
type subagentJSONOutput struct {
|
||||
Response string `json:"response"`
|
||||
StopReason string `json:"stop_reason,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Usage *struct {
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
} `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
var subagentCounter atomic.Uint64
|
||||
|
||||
func generateSubagentID() string {
|
||||
n := subagentCounter.Add(1)
|
||||
return fmt.Sprintf("sub-%d-%d", time.Now().UnixNano(), n)
|
||||
}
|
||||
|
||||
func findKitBinary() string {
|
||||
// Try the current process executable first.
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
if _, err := os.Stat(exe); err == nil {
|
||||
return exe
|
||||
}
|
||||
}
|
||||
// Fall back to PATH lookup.
|
||||
if p, err := exec.LookPath("kit"); err == nil {
|
||||
return p
|
||||
}
|
||||
return "kit"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SpawnSubagent implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SpawnSubagent spawns a child Kit instance to perform a task.
|
||||
//
|
||||
// When config.Blocking is true, blocks until completion and returns the result
|
||||
// directly (handle is nil). When false, returns immediately with a handle for
|
||||
// monitoring/cancellation.
|
||||
//
|
||||
// The subagent runs with --json --no-session --no-extensions flags by default,
|
||||
// ensuring isolation from the parent's extensions and session state.
|
||||
func SpawnSubagent(cfg SubagentConfig) (*SubagentHandle, *SubagentResult, error) {
|
||||
if cfg.Prompt == "" {
|
||||
return nil, nil, fmt.Errorf("prompt is required")
|
||||
}
|
||||
|
||||
timeout := cfg.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 5 * time.Minute
|
||||
}
|
||||
|
||||
kitBinary := findKitBinary()
|
||||
|
||||
// Build subprocess arguments.
|
||||
args := []string{
|
||||
"--json",
|
||||
"--no-extensions",
|
||||
}
|
||||
if cfg.NoSession {
|
||||
args = append(args, "--no-session")
|
||||
}
|
||||
if cfg.Model != "" {
|
||||
args = append(args, "--model", cfg.Model)
|
||||
}
|
||||
|
||||
// Handle system prompt - write to temp file if provided.
|
||||
var tmpFile *os.File
|
||||
if cfg.SystemPrompt != "" {
|
||||
var err error
|
||||
tmpFile, err = os.CreateTemp("", "kit-subagent-*.txt")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
if _, err := tmpFile.WriteString(cfg.SystemPrompt); err != nil {
|
||||
_ = tmpFile.Close()
|
||||
_ = os.Remove(tmpFile.Name())
|
||||
return nil, nil, fmt.Errorf("write system prompt: %w", err)
|
||||
}
|
||||
_ = tmpFile.Close()
|
||||
args = append(args, "--system-prompt", tmpFile.Name())
|
||||
}
|
||||
|
||||
// Add the prompt as a positional argument.
|
||||
args = append(args, cfg.Prompt)
|
||||
|
||||
// Create command with timeout context.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
|
||||
cmd := exec.CommandContext(ctx, kitBinary, args...)
|
||||
cmd.Env = os.Environ()
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
if tmpFile != nil {
|
||||
_ = os.Remove(tmpFile.Name())
|
||||
}
|
||||
return nil, nil, fmt.Errorf("stdout pipe: %w", err)
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
if tmpFile != nil {
|
||||
_ = os.Remove(tmpFile.Name())
|
||||
}
|
||||
return nil, nil, fmt.Errorf("stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
handle := &SubagentHandle{
|
||||
ID: generateSubagentID(),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
// Start the subprocess.
|
||||
start := time.Now()
|
||||
if err := cmd.Start(); err != nil {
|
||||
cancel()
|
||||
if tmpFile != nil {
|
||||
_ = os.Remove(tmpFile.Name())
|
||||
}
|
||||
return nil, nil, fmt.Errorf("start subprocess: %w", err)
|
||||
}
|
||||
|
||||
handle.mu.Lock()
|
||||
handle.proc = cmd.Process
|
||||
handle.mu.Unlock()
|
||||
|
||||
// Run the subprocess monitoring in a goroutine.
|
||||
go func() {
|
||||
defer close(handle.done)
|
||||
defer cancel()
|
||||
if tmpFile != nil {
|
||||
defer func() { _ = os.Remove(tmpFile.Name()) }()
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var stdoutBuf strings.Builder
|
||||
|
||||
// Read stderr (live output).
|
||||
wg.Go(func() {
|
||||
scanner := bufio.NewScanner(stderr)
|
||||
scanner.Buffer(make([]byte, 256*1024), 256*1024)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if cfg.OnOutput != nil && strings.TrimSpace(line) != "" {
|
||||
cfg.OnOutput(line + "\n")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Read stdout (JSON output).
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 256*1024), 256*1024)
|
||||
for scanner.Scan() {
|
||||
stdoutBuf.WriteString(scanner.Text() + "\n")
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
waitErr := cmd.Wait()
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// Build result.
|
||||
result := SubagentResult{Elapsed: elapsed}
|
||||
if waitErr != nil {
|
||||
result.Error = waitErr
|
||||
if exitErr, ok := waitErr.(*exec.ExitError); ok {
|
||||
result.ExitCode = exitErr.ExitCode()
|
||||
} else {
|
||||
result.ExitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
// Parse JSON output.
|
||||
raw := strings.TrimSpace(stdoutBuf.String())
|
||||
var parsed subagentJSONOutput
|
||||
if raw != "" && json.Unmarshal([]byte(raw), &parsed) == nil {
|
||||
result.Response = parsed.Response
|
||||
result.SessionID = parsed.SessionID
|
||||
if parsed.Usage != nil {
|
||||
result.Usage = &SubagentUsage{
|
||||
InputTokens: parsed.Usage.InputTokens,
|
||||
OutputTokens: parsed.Usage.OutputTokens,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: use raw stdout.
|
||||
result.Response = raw
|
||||
}
|
||||
|
||||
handle.mu.Lock()
|
||||
handle.result = &result
|
||||
handle.proc = nil
|
||||
handle.mu.Unlock()
|
||||
|
||||
if cfg.OnComplete != nil {
|
||||
cfg.OnComplete(result)
|
||||
}
|
||||
}()
|
||||
|
||||
if cfg.Blocking {
|
||||
// Wait for completion and return result directly.
|
||||
<-handle.done
|
||||
handle.mu.Lock()
|
||||
r := handle.result
|
||||
handle.mu.Unlock()
|
||||
return nil, r, nil
|
||||
}
|
||||
|
||||
return handle, nil, nil
|
||||
}
|
||||
|
||||
@@ -152,6 +152,9 @@ func Symbols() interp.Exports {
|
||||
// Event structs
|
||||
"ToolCallEvent": reflect.ValueOf((*ToolCallEvent)(nil)),
|
||||
"ToolCallResult": reflect.ValueOf((*ToolCallResult)(nil)),
|
||||
"ToolCallInputStartEvent": reflect.ValueOf((*ToolCallInputStartEvent)(nil)),
|
||||
"ToolCallInputDeltaEvent": reflect.ValueOf((*ToolCallInputDeltaEvent)(nil)),
|
||||
"ToolCallInputEndEvent": reflect.ValueOf((*ToolCallInputEndEvent)(nil)),
|
||||
"ToolExecutionStartEvent": reflect.ValueOf((*ToolExecutionStartEvent)(nil)),
|
||||
"ToolExecutionEndEvent": reflect.ValueOf((*ToolExecutionEndEvent)(nil)),
|
||||
"ToolOutputEvent": reflect.ValueOf((*ToolOutputEvent)(nil)),
|
||||
@@ -169,6 +172,18 @@ func Symbols() interp.Exports {
|
||||
"SessionStartEvent": reflect.ValueOf((*SessionStartEvent)(nil)),
|
||||
"SessionShutdownEvent": reflect.ValueOf((*SessionShutdownEvent)(nil)),
|
||||
"ModelChangeEvent": reflect.ValueOf((*ModelChangeEvent)(nil)),
|
||||
|
||||
// Step lifecycle events
|
||||
"StepStartEvent": reflect.ValueOf((*StepStartEvent)(nil)),
|
||||
"StepFinishEvent": reflect.ValueOf((*StepFinishEvent)(nil)),
|
||||
"ReasoningStartEvent": reflect.ValueOf((*ReasoningStartEvent)(nil)),
|
||||
"WarningsEvent": reflect.ValueOf((*WarningsEvent)(nil)),
|
||||
"SourceEvent": reflect.ValueOf((*SourceEvent)(nil)),
|
||||
"ErrorEvent": reflect.ValueOf((*ErrorEvent)(nil)),
|
||||
"RetryEvent": reflect.ValueOf((*RetryEvent)(nil)),
|
||||
"PrepareStepEvent": reflect.ValueOf((*PrepareStepEvent)(nil)),
|
||||
"PrepareStepResult": reflect.ValueOf((*PrepareStepResult)(nil)),
|
||||
"LLMUsageEvent": reflect.ValueOf((*LLMUsageEvent)(nil)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,5 +189,11 @@ func NewTestAPI(ext *LoadedExtension) API {
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onLLMUsage: func(h func(LLMUsageEvent, Context)) {
|
||||
reg(LLMUsage, func(e Event, c Context) Result {
|
||||
h(e.(LLMUsageEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package extensions
|
||||
|
||||
// ToolKind constants classify what a tool does, enabling UIs to render
|
||||
// appropriate visualizations (e.g. diff view for edit tools, command+output
|
||||
// for execute tools) and file trackers to identify which results contain
|
||||
// modifications.
|
||||
//
|
||||
// This is the single source of truth for tool-kind classification; the
|
||||
// pkg/kit SDK re-exports these constants.
|
||||
const (
|
||||
ToolKindExecute = "execute" // Shell execution (bash)
|
||||
ToolKindEdit = "edit" // File modification (edit, write)
|
||||
ToolKindRead = "read" // File reading (read, ls)
|
||||
ToolKindSearch = "search" // Content/file search (grep, find)
|
||||
ToolKindSubagent = "agent" // Subagent spawning (subagent)
|
||||
)
|
||||
|
||||
// coreToolKinds maps built-in tool names to their kind classification.
|
||||
// MCP and extension tools without an entry default to ToolKindExecute.
|
||||
var coreToolKinds = map[string]string{
|
||||
"bash": ToolKindExecute,
|
||||
"edit": ToolKindEdit,
|
||||
"write": ToolKindEdit,
|
||||
"read": ToolKindRead,
|
||||
"ls": ToolKindRead,
|
||||
"grep": ToolKindSearch,
|
||||
"find": ToolKindSearch,
|
||||
"subagent": ToolKindSubagent,
|
||||
}
|
||||
|
||||
// ToolKindFor returns the ToolKind for a given tool name, defaulting to
|
||||
// ToolKindExecute for unknown tools (including MCP tools).
|
||||
func ToolKindFor(toolName string) string {
|
||||
if kind, ok := coreToolKinds[toolName]; ok {
|
||||
return kind
|
||||
}
|
||||
return ToolKindExecute
|
||||
}
|
||||
+24
-157
@@ -1,143 +1,32 @@
|
||||
package extensions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/mark3labs/kit/internal/watcher"
|
||||
)
|
||||
|
||||
// Watcher monitors extension directories for file changes and triggers
|
||||
// a reload callback when .go files are created, modified, or removed.
|
||||
// It uses fsnotify for kernel-level file notifications (inotify on Linux,
|
||||
// kqueue on macOS) with debouncing to coalesce rapid editor writes.
|
||||
type Watcher struct {
|
||||
watcher *fsnotify.Watcher
|
||||
onReload func()
|
||||
debounce time.Duration
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
mu sync.Mutex
|
||||
}
|
||||
// Watcher monitors extension directories for .go file changes and triggers
|
||||
// a reload callback when changes are detected. It is implemented in terms
|
||||
// of the general-purpose internal/watcher.ContentWatcher.
|
||||
//
|
||||
// Type-aliasing here lets existing call sites (cmd/root.go and the
|
||||
// watcher_test.go suite) keep using `extensions.NewWatcher` / `*Watcher`
|
||||
// without knowing about the underlying implementation.
|
||||
type Watcher = watcher.ContentWatcher
|
||||
|
||||
// NewWatcher creates a file watcher that monitors the given directories
|
||||
// for .go file changes. When a change is detected (after debouncing),
|
||||
// onReload is called. The watcher must be started with Start() and
|
||||
// stopped with Close().
|
||||
func NewWatcher(dirs []string, onReload func()) (*Watcher, error) {
|
||||
fsw, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating file watcher: %w", err)
|
||||
}
|
||||
|
||||
for _, dir := range dirs {
|
||||
// Watch the directory itself.
|
||||
if err := fsw.Add(dir); err != nil {
|
||||
log.Printf("DEBUG watcher: skipping directory: dir=%s err=%v", dir, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Also watch immediate subdirectories (for */main.go pattern).
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
subdir := filepath.Join(dir, entry.Name())
|
||||
if err := fsw.Add(subdir); err != nil {
|
||||
log.Printf("DEBUG watcher: skipping subdirectory: dir=%s err=%v", subdir, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &Watcher{
|
||||
watcher: fsw,
|
||||
onReload: onReload,
|
||||
debounce: 300 * time.Millisecond,
|
||||
done: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start begins watching for file changes. It blocks until the context
|
||||
// is cancelled or Close() is called. Typically called in a goroutine.
|
||||
func (w *Watcher) Start(ctx context.Context) {
|
||||
w.mu.Lock()
|
||||
ctx, w.cancel = context.WithCancel(ctx)
|
||||
w.mu.Unlock()
|
||||
|
||||
defer close(w.done)
|
||||
|
||||
var timer *time.Timer
|
||||
var timerC <-chan time.Time
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
return
|
||||
|
||||
case event, ok := <-w.watcher.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Only care about .go files.
|
||||
if !strings.HasSuffix(event.Name, ".go") {
|
||||
continue
|
||||
}
|
||||
|
||||
// React to write, create, remove, rename events.
|
||||
if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove|fsnotify.Rename) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("DEBUG watcher: file changed: file=%s op=%s", event.Name, event.Op)
|
||||
|
||||
// Debounce: reset timer on each event.
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
timer = time.NewTimer(w.debounce)
|
||||
timerC = timer.C
|
||||
|
||||
case <-timerC:
|
||||
timerC = nil
|
||||
timer = nil
|
||||
log.Printf("DEBUG watcher: reloading extensions")
|
||||
w.onReload()
|
||||
|
||||
case err, ok := <-w.watcher.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log.Printf("WARN watcher: error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close stops the watcher and releases resources.
|
||||
func (w *Watcher) Close() error {
|
||||
w.mu.Lock()
|
||||
cancel := w.cancel
|
||||
w.mu.Unlock()
|
||||
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
|
||||
// Wait for the event loop to finish.
|
||||
<-w.done
|
||||
return w.watcher.Close()
|
||||
return watcher.New(watcher.Options{
|
||||
Dirs: dirs,
|
||||
Extensions: []string{".go"},
|
||||
OnReload: onReload,
|
||||
Label: "extensions",
|
||||
})
|
||||
}
|
||||
|
||||
// WatchedDirs returns the directories to watch for extension changes.
|
||||
@@ -146,47 +35,25 @@ func (w *Watcher) Close() error {
|
||||
// point to directories are also included; explicit file paths cause
|
||||
// their parent directory to be watched instead.
|
||||
func WatchedDirs(extraPaths []string) []string {
|
||||
var dirs []string
|
||||
seen := make(map[string]bool)
|
||||
|
||||
add := func(dir string) {
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if seen[abs] {
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the directory exists.
|
||||
info, err := os.Stat(abs)
|
||||
if err != nil || !info.IsDir() {
|
||||
return
|
||||
}
|
||||
|
||||
seen[abs] = true
|
||||
dirs = append(dirs, abs)
|
||||
standard := []string{
|
||||
globalExtensionsDir(),
|
||||
filepath.Join(".kit", "extensions"),
|
||||
}
|
||||
|
||||
// Global extensions dir.
|
||||
add(globalExtensionsDir())
|
||||
|
||||
// Project-local extensions dir.
|
||||
add(filepath.Join(".kit", "extensions"))
|
||||
|
||||
// Explicit paths that are directories.
|
||||
// Filter explicit paths into directories (passed through) and files
|
||||
// (parent dir watched) for CollectDirs to dedupe.
|
||||
var extras []string
|
||||
for _, p := range extraPaths {
|
||||
info, err := os.Stat(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.IsDir() {
|
||||
add(p)
|
||||
extras = append(extras, p)
|
||||
} else {
|
||||
// For explicit files, watch the parent directory.
|
||||
add(filepath.Dir(p))
|
||||
extras = append(extras, filepath.Dir(p))
|
||||
}
|
||||
}
|
||||
|
||||
return dirs
|
||||
return watcher.CollectDirs(standard, extras)
|
||||
}
|
||||
|
||||
@@ -40,27 +40,6 @@ func ExtensionToolsAsLLMTools(defs []ToolDef, runner *Runner) []fantasy.AgentToo
|
||||
return tools
|
||||
}
|
||||
|
||||
// coreToolKinds maps built-in tool names to their kind classification.
|
||||
var coreToolKinds = map[string]string{
|
||||
"bash": "execute",
|
||||
"edit": "edit",
|
||||
"write": "edit",
|
||||
"read": "read",
|
||||
"ls": "read",
|
||||
"grep": "search",
|
||||
"find": "search",
|
||||
"subagent": "agent",
|
||||
}
|
||||
|
||||
// toolKindFor returns the ToolKind for a given tool name, defaulting to
|
||||
// "execute" for unknown tools (including MCP tools).
|
||||
func toolKindFor(toolName string) string {
|
||||
if kind, ok := coreToolKinds[toolName]; ok {
|
||||
return kind
|
||||
}
|
||||
return "execute"
|
||||
}
|
||||
|
||||
// parseToolArgsJSON attempts to parse JSON-encoded tool args into a map.
|
||||
// Returns nil on failure (non-fatal convenience parsing).
|
||||
func parseToolArgsJSON(input string) map[string]any {
|
||||
@@ -90,11 +69,10 @@ func (w *wrappedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.T
|
||||
// 0. Check if tool is disabled via SetActiveTools.
|
||||
if w.runner.IsToolDisabled(toolName) {
|
||||
return fantasy.NewTextErrorResponse(
|
||||
fmt.Sprintf("Error: tool %q is currently disabled", toolName)),
|
||||
fmt.Errorf("tool %q disabled by extension", toolName)
|
||||
fmt.Sprintf("Error: tool %q is currently disabled", toolName)), nil
|
||||
}
|
||||
|
||||
kind := toolKindFor(toolName)
|
||||
kind := ToolKindFor(toolName)
|
||||
|
||||
// 1. Emit ToolCall — extensions can block execution.
|
||||
if w.runner.HasHandlers(ToolCall) {
|
||||
@@ -111,8 +89,7 @@ func (w *wrappedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.T
|
||||
if reason == "" {
|
||||
reason = "blocked by extension"
|
||||
}
|
||||
return fantasy.NewTextErrorResponse(fmt.Sprintf("Error: %s", reason)),
|
||||
fmt.Errorf("tool blocked by extension: %s", reason)
|
||||
return fantasy.NewTextErrorResponse(fmt.Sprintf("Error: %s", reason)), nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,7 +215,7 @@ func (t *extensionTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), err
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
return fantasy.NewTextResponse(result), nil
|
||||
}
|
||||
|
||||
@@ -142,8 +142,8 @@ func TestWrappedTool_BlockExecution(t *testing.T) {
|
||||
if toolRan {
|
||||
t.Error("tool should not have run after block")
|
||||
}
|
||||
if err == nil {
|
||||
t.Error("expected error from blocked tool")
|
||||
if err != nil {
|
||||
t.Error("expected nil error for blocked tool (error is conveyed via IsError response)")
|
||||
}
|
||||
if resp.IsError != true {
|
||||
t.Error("expected IsError=true from blocked response")
|
||||
@@ -234,8 +234,8 @@ func TestExtensionTool_Error(t *testing.T) {
|
||||
|
||||
tools := ExtensionToolsAsLLMTools(defs, nil)
|
||||
resp, err := tools[0].Run(context.Background(), fantasy.ToolCall{Input: "x"})
|
||||
if err == nil {
|
||||
t.Error("expected error")
|
||||
if err != nil {
|
||||
t.Error("expected nil error (error is conveyed via IsError response)")
|
||||
}
|
||||
if !resp.IsError {
|
||||
t.Error("expected IsError=true")
|
||||
|
||||
+69
-42
@@ -46,9 +46,9 @@ type AgentSetupOptions struct {
|
||||
ToolWrapper func([]fantasy.AgentTool) []fantasy.AgentTool
|
||||
|
||||
// ProviderConfig, when non-nil, is used directly instead of calling
|
||||
// BuildProviderConfig(). Callers that already hold viperInitMu can
|
||||
// pre-build this and release the lock before calling SetupAgent, so the
|
||||
// slow agent/MCP initialisation runs concurrently with other New() calls.
|
||||
// BuildProviderConfig(). Callers (e.g. Kit.New) pre-build this from their
|
||||
// per-instance config store and pass it here, so the slow agent/MCP
|
||||
// initialisation can run without further config reads.
|
||||
ProviderConfig *models.ProviderConfig
|
||||
// Debug enables debug logging. When zero-value, viper is consulted.
|
||||
// Only meaningful when ProviderConfig is also set.
|
||||
@@ -72,6 +72,14 @@ type AgentSetupOptions struct {
|
||||
// OnMCPServerLoaded, if non-nil, is called when each MCP server finishes
|
||||
// loading (successfully or with error). Called from the background goroutine.
|
||||
OnMCPServerLoaded func(serverName string, toolCount int, err error)
|
||||
// MCPTaskConfig configures task-augmented tools/call execution. The
|
||||
// zero value preserves historical synchronous-only behaviour.
|
||||
MCPTaskConfig tools.MCPTaskConfig
|
||||
// Viper is the per-instance configuration store. When set, it is used for
|
||||
// any fallback config reads (debug, no-extensions, max-steps, stream,
|
||||
// extension paths) and is attached to the extension runner. When nil, the
|
||||
// process-global viper store is used.
|
||||
Viper *viper.Viper
|
||||
}
|
||||
|
||||
// AgentSetupResult bundles the created agent and any debug logger so the caller
|
||||
@@ -84,57 +92,62 @@ type AgentSetupResult struct {
|
||||
ExtRunner *extensions.Runner
|
||||
}
|
||||
|
||||
// BuildProviderConfig creates a *models.ProviderConfig from the current viper
|
||||
// state. All entry points (root, script, SDK) converge through this function.
|
||||
// BuildProviderConfig creates a *models.ProviderConfig from the supplied viper
|
||||
// store (or the process-global store when v is nil). All entry points (root,
|
||||
// script, SDK) converge through this function.
|
||||
//
|
||||
// Generation parameter pointers (Temperature, TopP, etc.) are only set when
|
||||
// the user has explicitly configured them via CLI flag, environment variable,
|
||||
// or global config file. This allows per-model defaults from modelSettings
|
||||
// and customModels to fill in unset parameters downstream.
|
||||
func BuildProviderConfig() (*models.ProviderConfig, string, error) {
|
||||
systemPrompt, err := config.LoadSystemPrompt(viper.GetString("system-prompt"))
|
||||
func BuildProviderConfig(v *viper.Viper) (*models.ProviderConfig, string, error) {
|
||||
if v == nil {
|
||||
v = viper.GetViper()
|
||||
}
|
||||
systemPrompt, err := config.LoadSystemPrompt(v.GetString("system-prompt"))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to load system prompt: %w", err)
|
||||
}
|
||||
|
||||
numGPU := int32(viper.GetInt("num-gpu-layers"))
|
||||
mainGPU := int32(viper.GetInt("main-gpu"))
|
||||
numGPU := int32(v.GetInt("num-gpu-layers"))
|
||||
mainGPU := int32(v.GetInt("main-gpu"))
|
||||
|
||||
cfg := &models.ProviderConfig{
|
||||
ModelString: viper.GetString("model"),
|
||||
ModelString: v.GetString("model"),
|
||||
SystemPrompt: systemPrompt,
|
||||
ProviderAPIKey: viper.GetString("provider-api-key"),
|
||||
ProviderURL: viper.GetString("provider-url"),
|
||||
MaxTokens: viper.GetInt("max-tokens"),
|
||||
StopSequences: viper.GetStringSlice("stop-sequences"),
|
||||
ProviderAPIKey: v.GetString("provider-api-key"),
|
||||
ProviderURL: v.GetString("provider-url"),
|
||||
MaxTokens: v.GetInt("max-tokens"),
|
||||
StopSequences: v.GetStringSlice("stop-sequences"),
|
||||
NumGPU: &numGPU,
|
||||
MainGPU: &mainGPU,
|
||||
TLSSkipVerify: viper.GetBool("tls-skip-verify"),
|
||||
ThinkingLevel: models.ParseThinkingLevel(viper.GetString("thinking-level")),
|
||||
TLSSkipVerify: v.GetBool("tls-skip-verify"),
|
||||
ThinkingLevel: models.ParseThinkingLevel(v.GetString("thinking-level")),
|
||||
ConfigStore: v,
|
||||
}
|
||||
|
||||
// Only set generation parameter pointers when the user has explicitly
|
||||
// provided a value. This leaves nil pointers for unset params, allowing
|
||||
// per-model defaults (modelSettings / customModels params) to apply.
|
||||
if viper.IsSet("temperature") {
|
||||
v := float32(viper.GetFloat64("temperature"))
|
||||
cfg.Temperature = &v
|
||||
if v.IsSet("temperature") {
|
||||
val := float32(v.GetFloat64("temperature"))
|
||||
cfg.Temperature = &val
|
||||
}
|
||||
if viper.IsSet("top-p") {
|
||||
v := float32(viper.GetFloat64("top-p"))
|
||||
cfg.TopP = &v
|
||||
if v.IsSet("top-p") {
|
||||
val := float32(v.GetFloat64("top-p"))
|
||||
cfg.TopP = &val
|
||||
}
|
||||
if viper.IsSet("top-k") {
|
||||
v := int32(viper.GetInt("top-k"))
|
||||
cfg.TopK = &v
|
||||
if v.IsSet("top-k") {
|
||||
val := int32(v.GetInt("top-k"))
|
||||
cfg.TopK = &val
|
||||
}
|
||||
if viper.IsSet("frequency-penalty") {
|
||||
v := float32(viper.GetFloat64("frequency-penalty"))
|
||||
cfg.FrequencyPenalty = &v
|
||||
if v.IsSet("frequency-penalty") {
|
||||
val := float32(v.GetFloat64("frequency-penalty"))
|
||||
cfg.FrequencyPenalty = &val
|
||||
}
|
||||
if viper.IsSet("presence-penalty") {
|
||||
v := float32(viper.GetFloat64("presence-penalty"))
|
||||
cfg.PresencePenalty = &v
|
||||
if v.IsSet("presence-penalty") {
|
||||
val := float32(v.GetFloat64("presence-penalty"))
|
||||
cfg.PresencePenalty = &val
|
||||
}
|
||||
|
||||
return cfg, systemPrompt, nil
|
||||
@@ -146,14 +159,21 @@ func SetupAgent(ctx context.Context, opts AgentSetupOptions) (*AgentSetupResult,
|
||||
var modelConfig *models.ProviderConfig
|
||||
var systemPrompt string
|
||||
|
||||
// Resolve the config store: prefer the per-instance store, falling back to
|
||||
// the process-global store.
|
||||
v := opts.Viper
|
||||
if v == nil {
|
||||
v = viper.GetViper()
|
||||
}
|
||||
|
||||
if opts.ProviderConfig != nil {
|
||||
// Pre-built config supplied by caller (e.g. Kit.New after releasing
|
||||
// viperInitMu). Use it directly — no viper reads needed here.
|
||||
// Pre-built config supplied by caller (e.g. Kit.New after building the
|
||||
// per-instance store). Use it directly — no viper reads needed here.
|
||||
modelConfig = opts.ProviderConfig
|
||||
systemPrompt = modelConfig.SystemPrompt
|
||||
} else {
|
||||
var err error
|
||||
modelConfig, systemPrompt, err = BuildProviderConfig()
|
||||
modelConfig, systemPrompt, err = BuildProviderConfig(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -161,13 +181,13 @@ func SetupAgent(ctx context.Context, opts AgentSetupOptions) (*AgentSetupResult,
|
||||
|
||||
// Resolve debug / no-extensions / max-steps / streaming: prefer explicit
|
||||
// fields (set when ProviderConfig was pre-built) over viper fallback.
|
||||
debugEnabled := opts.Debug || viper.GetBool("debug")
|
||||
noExtensions := opts.NoExtensions || viper.GetBool("no-extensions")
|
||||
debugEnabled := opts.Debug || v.GetBool("debug")
|
||||
noExtensions := opts.NoExtensions || v.GetBool("no-extensions")
|
||||
maxSteps := opts.MaxSteps
|
||||
if maxSteps == 0 {
|
||||
maxSteps = viper.GetInt("max-steps")
|
||||
maxSteps = v.GetInt("max-steps")
|
||||
}
|
||||
streamingEnabled := opts.StreamingEnabled || viper.GetBool("stream")
|
||||
streamingEnabled := opts.StreamingEnabled || v.GetBool("stream")
|
||||
|
||||
// Create the appropriate debug logger.
|
||||
var debugLogger tools.DebugLogger
|
||||
@@ -186,7 +206,7 @@ func SetupAgent(ctx context.Context, opts AgentSetupOptions) (*AgentSetupResult,
|
||||
var extCreationOpts extensionCreationOpts
|
||||
if !noExtensions {
|
||||
var extErr error
|
||||
extRunner, extCreationOpts, extErr = loadExtensions()
|
||||
extRunner, extCreationOpts, extErr = loadExtensions(v)
|
||||
if extErr != nil {
|
||||
fmt.Printf("Warning: Failed to load extensions: %v\n", extErr)
|
||||
}
|
||||
@@ -229,6 +249,7 @@ func SetupAgent(ctx context.Context, opts AgentSetupOptions) (*AgentSetupResult,
|
||||
ToolWrapper: toolWrapper,
|
||||
ExtraTools: extraTools,
|
||||
OnMCPServerLoaded: opts.OnMCPServerLoaded,
|
||||
MCPTaskConfig: opts.MCPTaskConfig,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create agent: %w", err)
|
||||
@@ -249,9 +270,14 @@ type extensionCreationOpts struct {
|
||||
}
|
||||
|
||||
// loadExtensions discovers and loads Yaegi extensions, builds the runner,
|
||||
// and returns the tool wrapper/extra tools.
|
||||
func loadExtensions() (*extensions.Runner, extensionCreationOpts, error) {
|
||||
extraPaths := viper.GetStringSlice("extension")
|
||||
// and returns the tool wrapper/extra tools. The supplied store is used to
|
||||
// resolve the "extension" config key and is attached to the runner so
|
||||
// extension option lookups stay isolated to this Kit instance.
|
||||
func loadExtensions(v *viper.Viper) (*extensions.Runner, extensionCreationOpts, error) {
|
||||
if v == nil {
|
||||
v = viper.GetViper()
|
||||
}
|
||||
extraPaths := v.GetStringSlice("extension")
|
||||
loaded, err := extensions.LoadExtensions(extraPaths)
|
||||
if err != nil {
|
||||
return nil, extensionCreationOpts{}, err
|
||||
@@ -262,6 +288,7 @@ func loadExtensions() (*extensions.Runner, extensionCreationOpts, error) {
|
||||
}
|
||||
|
||||
runner := extensions.NewRunner(loaded)
|
||||
runner.SetConfigStore(v)
|
||||
|
||||
wrapper := func(tools []fantasy.AgentTool) []fantasy.AgentTool {
|
||||
return extensions.WrapToolsWithExtensions(tools, runner)
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestNpmToWireProtocol documents the wire protocols that the auto-router
|
||||
// understands. Provider-specific bundles that need bespoke auth or URL
|
||||
// templating (azure, bedrock, openrouter, google-vertex*, @ai-sdk/gateway)
|
||||
// are intentionally absent — they have native top-level cases in
|
||||
// CreateProvider and never reach the auto-router.
|
||||
func TestNpmToWireProtocol(t *testing.T) {
|
||||
want := map[string]wireProtocol{
|
||||
"@ai-sdk/openai": wireOpenAI,
|
||||
"@ai-sdk/openai-compatible": wireOpenAI,
|
||||
"@ai-sdk/anthropic": wireAnthropic,
|
||||
"@ai-sdk/google": wireGoogle,
|
||||
|
||||
// Thin OpenAI-compatible wrappers — routed via openaicompat using
|
||||
// the SDK's hard-coded default base URL (sdkDefaultBaseURL).
|
||||
"@ai-sdk/groq": wireOpenAI,
|
||||
"@ai-sdk/cerebras": wireOpenAI,
|
||||
"@ai-sdk/perplexity": wireOpenAI,
|
||||
"@ai-sdk/togetherai": wireOpenAI,
|
||||
"@ai-sdk/xai": wireOpenAI,
|
||||
"@ai-sdk/deepinfra": wireOpenAI,
|
||||
"@ai-sdk/mistral": wireOpenAI,
|
||||
"@ai-sdk/cohere": wireOpenAI,
|
||||
"@ai-sdk/vercel": wireOpenAI,
|
||||
"@aihubmix/ai-sdk-provider": wireOpenAI,
|
||||
"venice-ai-sdk-provider": wireOpenAI,
|
||||
"merge-gateway-ai-sdk-provider": wireOpenAI,
|
||||
}
|
||||
for npm, wire := range want {
|
||||
if got := npmToWireProtocol[npm]; got != wire {
|
||||
t.Errorf("npmToWireProtocol[%q] = %d, want %d", npm, got, wire)
|
||||
}
|
||||
}
|
||||
|
||||
// Bundle packages must NOT be in the table — they need bespoke auth or
|
||||
// URL templating that the auto-router cannot satisfy.
|
||||
for _, npm := range []string{
|
||||
"@ai-sdk/google-vertex",
|
||||
"@ai-sdk/google-vertex/anthropic",
|
||||
"@ai-sdk/amazon-bedrock",
|
||||
"@ai-sdk/azure",
|
||||
"@openrouter/ai-sdk-provider",
|
||||
"@ai-sdk/gateway",
|
||||
} {
|
||||
if _, ok := npmToWireProtocol[npm]; ok {
|
||||
t.Errorf("npmToWireProtocol unexpectedly contains bundle package %q", npm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// newTestRegistry builds a registry containing a single proxy-style provider
|
||||
// ("testproxy") with the given default npm, plus one model that carries the
|
||||
// given per-model npm override.
|
||||
func newTestRegistry(api, defaultNPM, modelID, modelNPMOverride string) *ModelsRegistry {
|
||||
return &ModelsRegistry{
|
||||
providers: map[string]ProviderInfo{
|
||||
"testproxy": {
|
||||
ID: "testproxy",
|
||||
Name: "Test Proxy",
|
||||
Env: []string{"TESTPROXY_API_KEY"},
|
||||
NPM: defaultNPM,
|
||||
API: api,
|
||||
Models: map[string]ModelInfo{
|
||||
modelID: {
|
||||
ID: modelID,
|
||||
Name: modelID,
|
||||
ProviderNPM: modelNPMOverride,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoRouteProvider_WireRouting verifies that autoRouteProvider routes each
|
||||
// npm package to the correct fantasy provider implementation. This is the core
|
||||
// regression test for issue #41: previously any npm that resolved to a
|
||||
// non-openai/anthropic/openaicompat LLM provider (notably @ai-sdk/google) hit a
|
||||
// dead `default` branch and failed with "has no LLM provider mapping".
|
||||
func TestAutoRouteProvider_WireRouting(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
modelID string
|
||||
defaultNPM string
|
||||
overrideNPM string
|
||||
// wantType is the concrete fantasy LanguageModel type the model should
|
||||
// be routed to, identified by reflect type string.
|
||||
wantType string
|
||||
}{
|
||||
{
|
||||
name: "openai-compatible default",
|
||||
modelID: "test-model",
|
||||
defaultNPM: "@ai-sdk/openai-compatible",
|
||||
wantType: "openai.languageModel",
|
||||
},
|
||||
{
|
||||
name: "anthropic override",
|
||||
modelID: "test-model",
|
||||
defaultNPM: "@ai-sdk/openai-compatible",
|
||||
overrideNPM: "@ai-sdk/anthropic",
|
||||
wantType: "anthropic.languageModel",
|
||||
},
|
||||
{
|
||||
name: "openai (responses) override",
|
||||
modelID: "gpt-4o",
|
||||
defaultNPM: "@ai-sdk/openai-compatible",
|
||||
overrideNPM: "@ai-sdk/openai",
|
||||
wantType: "openai.responsesLanguageModel",
|
||||
},
|
||||
{
|
||||
// The bug: opencode's gemini-* models override the default
|
||||
// openai-compatible npm with @ai-sdk/google.
|
||||
name: "google override (issue #41)",
|
||||
modelID: "gemini-3.5-flash",
|
||||
defaultNPM: "@ai-sdk/openai-compatible",
|
||||
overrideNPM: "@ai-sdk/google",
|
||||
wantType: "*google.languageModel",
|
||||
},
|
||||
{
|
||||
// Unknown npm but provider has an API URL → openai-compatible fallback.
|
||||
name: "unknown npm with API URL falls back to openai-compat",
|
||||
modelID: "test-model",
|
||||
defaultNPM: "@ai-sdk/some-future-thing",
|
||||
wantType: "openai.languageModel",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reg := newTestRegistry("https://proxy.example/v1", tt.defaultNPM, tt.modelID, tt.overrideNPM)
|
||||
config := &ProviderConfig{ProviderAPIKey: "test-key"}
|
||||
|
||||
result, err := autoRouteProvider(context.Background(), config, "testproxy", tt.modelID, reg)
|
||||
if err != nil {
|
||||
t.Fatalf("autoRouteProvider returned error: %v", err)
|
||||
}
|
||||
if result == nil || result.Model == nil {
|
||||
t.Fatalf("autoRouteProvider returned nil model")
|
||||
}
|
||||
|
||||
gotType := reflect.TypeOf(result.Model).String()
|
||||
if gotType != tt.wantType {
|
||||
t.Errorf("routed to %s, want %s", gotType, tt.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoRouteProvider_UnknownNpmNoAPI verifies the improved error message for
|
||||
// a provider whose npm has no known wire protocol and that has no API URL to
|
||||
// fall back on.
|
||||
func TestAutoRouteProvider_UnknownNpmNoAPI(t *testing.T) {
|
||||
reg := newTestRegistry("", "@ai-sdk/unmapped", "test-model", "")
|
||||
config := &ProviderConfig{ProviderAPIKey: "test-key"}
|
||||
|
||||
_, err := autoRouteProvider(context.Background(), config, "testproxy", "test-model", reg)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown npm with no API URL, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot auto-route provider testproxy") {
|
||||
t.Errorf("unexpected error message: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--provider-url") {
|
||||
t.Errorf("error should suggest --provider-url, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoRouteProvider_UnknownProvider verifies the not-in-database error.
|
||||
func TestAutoRouteProvider_UnknownProvider(t *testing.T) {
|
||||
reg := newTestRegistry("https://proxy.example/v1", "@ai-sdk/openai-compatible", "test-model", "")
|
||||
config := &ProviderConfig{ProviderAPIKey: "test-key"}
|
||||
|
||||
_, err := autoRouteProvider(context.Background(), config, "does-not-exist", "test-model", reg)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown provider, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not found in model database") {
|
||||
t.Errorf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsProviderLLMSupported_Google verifies that a provider whose npm is
|
||||
// @ai-sdk/google is reported as supported (it now maps to a wire protocol).
|
||||
func TestIsProviderLLMSupported_Google(t *testing.T) {
|
||||
info := &ProviderInfo{ID: "testproxy", NPM: "@ai-sdk/google"}
|
||||
if !isProviderLLMSupported("testproxy", info) {
|
||||
t.Error("expected @ai-sdk/google provider to be LLM-supported")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVersionedBasePath verifies detection of proxy base URLs that already
|
||||
// carry an API version segment (which collides with the genai SDK's injected
|
||||
// version).
|
||||
func TestVersionedBasePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
rawURL string
|
||||
want string
|
||||
}{
|
||||
{"https://opencode.ai/zen/v1", "/zen/v1"},
|
||||
{"https://opencode.ai/zen/v1/", "/zen/v1"},
|
||||
{"https://example.com/api/v1beta", "/api/v1beta"},
|
||||
{"https://example.com/api/v2alpha", "/api/v2alpha"},
|
||||
{"https://generativelanguage.googleapis.com", ""},
|
||||
{"https://proxy.example/openai", ""},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := versionedBasePath(tt.rawURL); got != tt.want {
|
||||
t.Errorf("versionedBasePath(%q) = %q, want %q", tt.rawURL, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// recordingRoundTripper captures the path of the request it receives.
|
||||
type recordingRoundTripper struct{ gotPath string }
|
||||
|
||||
func (r *recordingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
r.gotPath = req.URL.Path
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(strings.NewReader("{}")),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TestGeminiProxyTransport_StripsInjectedVersion verifies that the transport
|
||||
// collapses the genai-injected "/v1beta" segment that follows a proxy base
|
||||
// URL which already carries its own version segment. This is the second-order
|
||||
// fix that makes opencode/gemini-* actually reach the proxy (issue #41).
|
||||
func TestGeminiProxyTransport_StripsInjectedVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
basePath string
|
||||
reqPath string
|
||||
wantPath string
|
||||
}{
|
||||
{
|
||||
name: "strips doubled v1beta after /zen/v1",
|
||||
basePath: "/zen/v1",
|
||||
reqPath: "/zen/v1/v1beta/models/gemini-3.5-flash:generateContent",
|
||||
wantPath: "/zen/v1/models/gemini-3.5-flash:generateContent",
|
||||
},
|
||||
{
|
||||
name: "strips doubled v1beta1 after /zen/v1",
|
||||
basePath: "/zen/v1",
|
||||
reqPath: "/zen/v1/v1beta1/models/gemini-3.5-flash:generateContent",
|
||||
wantPath: "/zen/v1/models/gemini-3.5-flash:generateContent",
|
||||
},
|
||||
{
|
||||
name: "leaves non-matching path untouched",
|
||||
basePath: "/zen/v1",
|
||||
reqPath: "/other/v1beta/models/x:generateContent",
|
||||
wantPath: "/other/v1beta/models/x:generateContent",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rec := &recordingRoundTripper{}
|
||||
tr := &geminiProxyTransport{base: rec, basePath: tt.basePath}
|
||||
req, err := http.NewRequest(http.MethodPost, "https://host"+tt.reqPath, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest: %v", err)
|
||||
}
|
||||
if _, err := tr.RoundTrip(req); err != nil {
|
||||
t.Fatalf("RoundTrip: %v", err)
|
||||
}
|
||||
if rec.gotPath != tt.wantPath {
|
||||
t.Errorf("forwarded path = %q, want %q", rec.gotPath, tt.wantPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package models
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"maps"
|
||||
"os"
|
||||
|
||||
"charm.land/fantasy"
|
||||
@@ -69,19 +68,3 @@ func generateCacheKey(systemPrompt, modelID string) string {
|
||||
// Prefix with "kit-" to identify KIT-generated cache keys
|
||||
return "kit-" + hex.EncodeToString(h.Sum(nil))[:24]
|
||||
}
|
||||
|
||||
// mergeProviderOptions merges multiple ProviderOptions maps.
|
||||
// Later maps take precedence over earlier ones.
|
||||
func mergeProviderOptions(opts ...fantasy.ProviderOptions) fantasy.ProviderOptions {
|
||||
result := make(fantasy.ProviderOptions)
|
||||
|
||||
for _, opt := range opts {
|
||||
maps.Copy(result, opt)
|
||||
}
|
||||
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ package models
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
)
|
||||
|
||||
func TestModelInfo_SupportsCaching(t *testing.T) {
|
||||
@@ -192,57 +190,3 @@ func TestCachingPriorityOverThinking(t *testing.T) {
|
||||
t.Errorf("OpenAI caching should work when thinking is OFF")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeProviderOptions(t *testing.T) {
|
||||
opts1 := fantasy.ProviderOptions{
|
||||
"provider1": &testProviderData{value: "value1"},
|
||||
}
|
||||
opts2 := fantasy.ProviderOptions{
|
||||
"provider2": &testProviderData{value: "value2"},
|
||||
}
|
||||
|
||||
merged := mergeProviderOptions(opts1, opts2)
|
||||
|
||||
if len(merged) != 2 {
|
||||
t.Errorf("mergeProviderOptions should combine options from multiple maps, got %d items", len(merged))
|
||||
}
|
||||
|
||||
if _, ok := merged["provider1"]; !ok {
|
||||
t.Errorf("merged options should contain 'provider1' key")
|
||||
}
|
||||
|
||||
if _, ok := merged["provider2"]; !ok {
|
||||
t.Errorf("merged options should contain 'provider2' key")
|
||||
}
|
||||
|
||||
// Later options should override earlier ones
|
||||
opts3 := fantasy.ProviderOptions{
|
||||
"provider1": &testProviderData{value: "overridden"},
|
||||
}
|
||||
merged2 := mergeProviderOptions(opts1, opts3)
|
||||
|
||||
if data, ok := merged2["provider1"].(*testProviderData); ok {
|
||||
if data.value != "overridden" {
|
||||
t.Errorf("later options should override earlier ones, got %q", data.value)
|
||||
}
|
||||
}
|
||||
|
||||
if mergeProviderOptions() != nil {
|
||||
t.Errorf("mergeProviderOptions with no args should return nil")
|
||||
}
|
||||
}
|
||||
|
||||
// testProviderData is a simple implementation of ProviderOptionsData for testing
|
||||
type testProviderData struct {
|
||||
value string
|
||||
}
|
||||
|
||||
func (t *testProviderData) Options() {}
|
||||
|
||||
func (t *testProviderData) MarshalJSON() ([]byte, error) {
|
||||
return []byte(`"` + t.value + `"`), nil
|
||||
}
|
||||
|
||||
func (t *testProviderData) UnmarshalJSON(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCopilotProviderAliasUsesCatalog(t *testing.T) {
|
||||
registry := NewModelsRegistry()
|
||||
|
||||
models, err := registry.GetModelsForProvider("copilot")
|
||||
if err != nil {
|
||||
t.Fatalf("GetModelsForProvider(copilot) failed: %v", err)
|
||||
}
|
||||
if len(models) == 0 {
|
||||
t.Fatal("expected copilot alias to return github-copilot catalog models")
|
||||
}
|
||||
if registry.LookupModel("copilot", "gpt-5.5") == nil {
|
||||
t.Fatal("expected copilot/gpt-5.5 to resolve through github-copilot catalog")
|
||||
}
|
||||
if registry.GetProviderInfo("copilot") == nil {
|
||||
t.Fatal("expected copilot alias to return github-copilot provider info")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopilotRejectsNonGPTModels(t *testing.T) {
|
||||
_, err := CreateProvider(t.Context(), &ProviderConfig{ModelString: "copilot/claude-sonnet-4.6"})
|
||||
if err == nil {
|
||||
t.Fatal("expected non-GPT Copilot model to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopilotHTTPClientCachesToken(t *testing.T) {
|
||||
client := createCopilotHTTPClient("cached-token", time.Now().Add(time.Hour).Unix(), false)
|
||||
transport, ok := client.Transport.(*copilotTransport)
|
||||
if !ok {
|
||||
t.Fatal("expected *copilotTransport")
|
||||
}
|
||||
|
||||
token := transport.cachedToken(t.Context())
|
||||
if token != "cached-token" {
|
||||
t.Fatalf("expected cached token, got %q", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopilotTransportHeaders(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest failed: %v", err)
|
||||
}
|
||||
|
||||
transport := &copilotTransport{
|
||||
base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.Header.Get("Authorization") != "Bearer cached-token" {
|
||||
t.Fatalf("unexpected Authorization header: %q", req.Header.Get("Authorization"))
|
||||
}
|
||||
if req.Header.Get("Copilot-Integration-Id") != copilotIntegrationID {
|
||||
t.Fatalf("unexpected Copilot-Integration-Id header: %q", req.Header.Get("Copilot-Integration-Id"))
|
||||
}
|
||||
if req.Header.Get("Editor-Version") != copilotEditorVersion {
|
||||
t.Fatalf("unexpected Editor-Version header: %q", req.Header.Get("Editor-Version"))
|
||||
}
|
||||
if req.Header.Get("User-Agent") != copilotUserAgent {
|
||||
t.Fatalf("unexpected User-Agent header: %q", req.Header.Get("User-Agent"))
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
|
||||
}),
|
||||
token: "cached-token",
|
||||
expiresAt: time.Now().Add(time.Hour).Unix(),
|
||||
}
|
||||
|
||||
resp, err := transport.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatalf("RoundTrip failed: %v", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
+46
-20
@@ -10,14 +10,24 @@ import (
|
||||
|
||||
// loadCustomModelsFromConfig loads custom model definitions from the config file
|
||||
// and returns them as a map of model ID -> ModelInfo. Returns nil if no custom
|
||||
// models are configured.
|
||||
// models are configured. Reads from the process-global viper store (the model
|
||||
// registry is a process-global singleton).
|
||||
func loadCustomModelsFromConfig() map[string]ModelInfo {
|
||||
if !viper.IsSet("customModels") {
|
||||
return loadCustomModelsFrom(viper.GetViper())
|
||||
}
|
||||
|
||||
// loadCustomModelsFrom loads custom model definitions from the supplied store.
|
||||
// When v is nil the process-global store is used.
|
||||
func loadCustomModelsFrom(v *viper.Viper) map[string]ModelInfo {
|
||||
if v == nil {
|
||||
v = viper.GetViper()
|
||||
}
|
||||
if !v.IsSet("customModels") {
|
||||
return nil
|
||||
}
|
||||
|
||||
var customModels map[string]CustomModelConfig
|
||||
if err := viper.UnmarshalKey("customModels", &customModels); err != nil {
|
||||
if err := v.UnmarshalKey("customModels", &customModels); err != nil {
|
||||
log.Printf("Warning: Failed to parse customModels: %v", err)
|
||||
return nil
|
||||
}
|
||||
@@ -59,16 +69,20 @@ func modelConfigToModelInfo(modelID string, cfg CustomModelConfig) ModelInfo {
|
||||
return info
|
||||
}
|
||||
|
||||
// LoadModelSettingsFromConfig loads per-model generation parameter overrides
|
||||
// from the config file. Keys are "provider/model" strings. Returns nil if
|
||||
// no model settings are configured.
|
||||
func LoadModelSettingsFromConfig() map[string]*GenerationParams {
|
||||
if !viper.IsSet("modelSettings") {
|
||||
// LoadModelSettingsFrom loads per-model generation parameter overrides from the
|
||||
// supplied per-instance store. When v is nil the process-global store is used.
|
||||
// Keys are "provider/model" strings. Returns nil if no model settings are
|
||||
// configured.
|
||||
func LoadModelSettingsFrom(v *viper.Viper) map[string]*GenerationParams {
|
||||
if v == nil {
|
||||
v = viper.GetViper()
|
||||
}
|
||||
if !v.IsSet("modelSettings") {
|
||||
return nil
|
||||
}
|
||||
|
||||
var settings map[string]GenerationParamsConfig
|
||||
if err := viper.UnmarshalKey("modelSettings", &settings); err != nil {
|
||||
if err := v.UnmarshalKey("modelSettings", &settings); err != nil {
|
||||
log.Printf("Warning: Failed to parse modelSettings: %v", err)
|
||||
return nil
|
||||
}
|
||||
@@ -148,12 +162,17 @@ func ApplyModelSettings(config *ProviderConfig, modelInfo *ModelInfo) {
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve the config store: prefer the per-instance store carried on the
|
||||
// ProviderConfig (set by BuildProviderConfig / Kit.New), falling back to
|
||||
// the process-global store for callers that don't thread one through.
|
||||
store := config.ConfigStore
|
||||
|
||||
// Collect model-level params: modelSettings override > custom model params.
|
||||
// modelSettings takes priority because it's the more specific/intentional config.
|
||||
var params *GenerationParams
|
||||
|
||||
// First check modelSettings from config.
|
||||
if settings := LoadModelSettingsFromConfig(); settings != nil {
|
||||
if settings := LoadModelSettingsFrom(store); settings != nil {
|
||||
modelKey := provider + "/" + modelName
|
||||
if p, ok := settings[modelKey]; ok {
|
||||
params = p
|
||||
@@ -173,28 +192,28 @@ func ApplyModelSettings(config *ProviderConfig, modelInfo *ModelInfo) {
|
||||
// We check viper.IsSet() which returns true only when the key was
|
||||
// set via CLI flag, environment variable, or config file global section.
|
||||
|
||||
if params.MaxTokens != nil && !isExplicitlySet("max-tokens") {
|
||||
if params.MaxTokens != nil && !isExplicitlySet(store, "max-tokens") {
|
||||
config.MaxTokens = *params.MaxTokens
|
||||
}
|
||||
if params.Temperature != nil && !isExplicitlySet("temperature") {
|
||||
if params.Temperature != nil && !isExplicitlySet(store, "temperature") {
|
||||
config.Temperature = params.Temperature
|
||||
}
|
||||
if params.TopP != nil && !isExplicitlySet("top-p") {
|
||||
if params.TopP != nil && !isExplicitlySet(store, "top-p") {
|
||||
config.TopP = params.TopP
|
||||
}
|
||||
if params.TopK != nil && !isExplicitlySet("top-k") {
|
||||
if params.TopK != nil && !isExplicitlySet(store, "top-k") {
|
||||
config.TopK = params.TopK
|
||||
}
|
||||
if params.FrequencyPenalty != nil && !isExplicitlySet("frequency-penalty") {
|
||||
if params.FrequencyPenalty != nil && !isExplicitlySet(store, "frequency-penalty") {
|
||||
config.FrequencyPenalty = params.FrequencyPenalty
|
||||
}
|
||||
if params.PresencePenalty != nil && !isExplicitlySet("presence-penalty") {
|
||||
if params.PresencePenalty != nil && !isExplicitlySet(store, "presence-penalty") {
|
||||
config.PresencePenalty = params.PresencePenalty
|
||||
}
|
||||
if len(params.StopSequences) > 0 && !isExplicitlySet("stop-sequences") {
|
||||
if len(params.StopSequences) > 0 && !isExplicitlySet(store, "stop-sequences") {
|
||||
config.StopSequences = params.StopSequences
|
||||
}
|
||||
if params.ThinkingLevel != "" && !isExplicitlySet("thinking-level") {
|
||||
if params.ThinkingLevel != "" && !isExplicitlySet(store, "thinking-level") {
|
||||
config.ThinkingLevel = params.ThinkingLevel
|
||||
}
|
||||
if params.SystemPrompt != "" && config.SystemPrompt == "" {
|
||||
@@ -228,7 +247,14 @@ func LoadSystemPromptValue(input string) string {
|
||||
// isExplicitlySet returns true when the user has explicitly set a config key
|
||||
// via CLI flag, environment variable, or the global section of the config file.
|
||||
// Model-level defaults should not override explicitly set values.
|
||||
func isExplicitlySet(key string) bool {
|
||||
//
|
||||
// The check runs against the supplied per-instance store when non-nil,
|
||||
// otherwise the process-global store. This keeps the "explicit vs unset"
|
||||
// precedence contract per-Kit-instance once a store is threaded through.
|
||||
func isExplicitlySet(v *viper.Viper, key string) bool {
|
||||
if v == nil {
|
||||
v = viper.GetViper()
|
||||
}
|
||||
// viper.IsSet returns true if the key has been set in any of the
|
||||
// data stores (flag, env, config file, default). We need to check
|
||||
// whether the value was set at the global config level (not just
|
||||
@@ -239,7 +265,7 @@ func isExplicitlySet(key string) bool {
|
||||
// file values. This means global config file values (e.g.
|
||||
// temperature: 0.7 at the top level) will correctly take precedence
|
||||
// over model-level defaults, which is the desired behavior.
|
||||
return viper.IsSet(key)
|
||||
return v.IsSet(key)
|
||||
}
|
||||
|
||||
// GenerationParams holds per-model generation parameter defaults.
|
||||
|
||||
File diff suppressed because one or more lines are too long
+83
-14
@@ -48,18 +48,87 @@ type modelsDBLimit struct {
|
||||
Output int `json:"output"`
|
||||
}
|
||||
|
||||
// npmToLLMProvider maps npm package names from models.dev to LLM
|
||||
// provider identifiers. Providers not in this map but with an api URL
|
||||
// can be auto-routed through openaicompat.
|
||||
var npmToLLMProvider = map[string]string{
|
||||
"@ai-sdk/anthropic": "anthropic",
|
||||
"@ai-sdk/openai": "openai",
|
||||
"@ai-sdk/google": "google",
|
||||
"@ai-sdk/google-vertex": "google-vertex",
|
||||
"@ai-sdk/google-vertex/anthropic": "google-vertex-anthropic",
|
||||
"@ai-sdk/amazon-bedrock": "bedrock",
|
||||
"@ai-sdk/azure": "azure",
|
||||
"@openrouter/ai-sdk-provider": "openrouter",
|
||||
"@ai-sdk/vercel": "vercel",
|
||||
"@ai-sdk/openai-compatible": "openaicompat",
|
||||
// wireProtocol identifies which LLM API protocol an npm package speaks.
|
||||
// Fantasy implements three native protocols (openai, anthropic, google);
|
||||
// everything else in its providers/ tree is a thin wrapper around one of
|
||||
// them with a pre-baked default URL or auth scheme.
|
||||
type wireProtocol int
|
||||
|
||||
const (
|
||||
wireUnknown wireProtocol = iota
|
||||
wireOpenAI
|
||||
wireAnthropic
|
||||
wireGoogle
|
||||
)
|
||||
|
||||
// npmToWireProtocol maps npm package names from models.dev to the wire
|
||||
// protocol they speak. Provider-specific bundles that need bespoke auth or
|
||||
// URL templating (azure, bedrock, openrouter, google-vertex, google-vertex-
|
||||
// anthropic, and @ai-sdk/gateway which is the Vercel AI Gateway) are
|
||||
// intentionally absent — they have native top-level cases in CreateProvider
|
||||
// and never reach the auto-router. Providers not in this map but with an
|
||||
// api URL are auto-routed through the OpenAI-compatible wire.
|
||||
//
|
||||
// The thin OpenAI-compatible npm wrappers (groq, cerebras, mistral, …) are
|
||||
// listed explicitly so that auto-routing can recover their hard-coded base
|
||||
// URL from sdkDefaultBaseURL when the registry entry has no api field.
|
||||
var npmToWireProtocol = map[string]wireProtocol{
|
||||
// Native wires.
|
||||
"@ai-sdk/openai": wireOpenAI,
|
||||
"@ai-sdk/openai-compatible": wireOpenAI,
|
||||
"@ai-sdk/anthropic": wireAnthropic,
|
||||
"@ai-sdk/google": wireGoogle,
|
||||
|
||||
// Thin OpenAI-compatible wrappers. Each ships with a hard-coded base URL
|
||||
// in its JS SDK (see sdkDefaultBaseURL) but speaks the plain OpenAI chat
|
||||
// completions wire — so we can route them all through fantasy's
|
||||
// openaicompat provider once we supply the URL.
|
||||
"@ai-sdk/groq": wireOpenAI,
|
||||
"@ai-sdk/cerebras": wireOpenAI,
|
||||
"@ai-sdk/perplexity": wireOpenAI,
|
||||
"@ai-sdk/togetherai": wireOpenAI,
|
||||
"@ai-sdk/xai": wireOpenAI,
|
||||
"@ai-sdk/deepinfra": wireOpenAI,
|
||||
"@ai-sdk/mistral": wireOpenAI,
|
||||
"@ai-sdk/cohere": wireOpenAI,
|
||||
"@ai-sdk/vercel": wireOpenAI, // v0 API (api.v0.dev), distinct from @ai-sdk/gateway
|
||||
"@aihubmix/ai-sdk-provider": wireOpenAI,
|
||||
"venice-ai-sdk-provider": wireOpenAI,
|
||||
"merge-gateway-ai-sdk-provider": wireOpenAI,
|
||||
}
|
||||
|
||||
// sdkDefaultBaseURL maps an npm package name to the base URL its JavaScript
|
||||
// SDK uses by default. This lets us recover a working endpoint for providers
|
||||
// whose models.dev entry omits the `api` field because the JS SDK hard-codes
|
||||
// the URL (e.g. groq, cerebras, mistral, x.ai…).
|
||||
//
|
||||
// Only OpenAI-compatible and native-wire SDKs are listed; providers needing
|
||||
// bespoke auth or URL templating (bedrock SigV4, azure resource URLs,
|
||||
// google-vertex project/location, cloudflare gateway account IDs, gitlab,
|
||||
// sap-ai-core) are handled by native CreateProvider cases or surface a
|
||||
// targeted error that asks the user to supply --provider-url.
|
||||
var sdkDefaultBaseURL = map[string]string{
|
||||
// Native wires.
|
||||
"@ai-sdk/openai": "https://api.openai.com/v1",
|
||||
"@ai-sdk/anthropic": "https://api.anthropic.com/v1",
|
||||
"@ai-sdk/google": "https://generativelanguage.googleapis.com/v1beta",
|
||||
|
||||
// Thin OpenAI-compatible wrappers.
|
||||
"@ai-sdk/groq": "https://api.groq.com/openai/v1",
|
||||
"@ai-sdk/cerebras": "https://api.cerebras.ai/v1",
|
||||
"@ai-sdk/perplexity": "https://api.perplexity.ai",
|
||||
"@ai-sdk/togetherai": "https://api.together.xyz/v1",
|
||||
"@ai-sdk/xai": "https://api.x.ai/v1",
|
||||
"@ai-sdk/deepinfra": "https://api.deepinfra.com/v1/openai",
|
||||
"@ai-sdk/mistral": "https://api.mistral.ai/v1",
|
||||
"@ai-sdk/cohere": "https://api.cohere.com/compatibility/v1",
|
||||
"@ai-sdk/vercel": "https://api.v0.dev/v1",
|
||||
"@aihubmix/ai-sdk-provider": "https://aihubmix.com/v1",
|
||||
"venice-ai-sdk-provider": "https://api.venice.ai/api/v1",
|
||||
"merge-gateway-ai-sdk-provider": "https://api-gateway.merge.dev/v1/ai-sdk",
|
||||
|
||||
// Native handlers — included for ResolveProviderBaseURL introspection
|
||||
// even though CreateProvider routes these via dedicated cases.
|
||||
"@ai-sdk/gateway": "https://ai-gateway.vercel.sh/v1",
|
||||
"@openrouter/ai-sdk-provider": "https://openrouter.ai/api/v1",
|
||||
}
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
)
|
||||
|
||||
// ProviderPool manages reusable LLM provider instances to reduce overhead
|
||||
// when spawning multiple subagents or making repeated completion calls.
|
||||
type ProviderPool struct {
|
||||
mu sync.RWMutex
|
||||
providers map[string]*pooledProvider
|
||||
ttl time.Duration
|
||||
closed bool
|
||||
closeCh chan struct{}
|
||||
}
|
||||
|
||||
type pooledProvider struct {
|
||||
model fantasy.LanguageModel
|
||||
closer func() error
|
||||
providerOpts fantasy.ProviderOptions
|
||||
created time.Time
|
||||
lastUsed time.Time
|
||||
refs int32
|
||||
}
|
||||
|
||||
// DefaultPoolTTL is the default time-to-live for idle pooled providers.
|
||||
const DefaultPoolTTL = 5 * time.Minute
|
||||
|
||||
// globalPool is the singleton provider pool instance.
|
||||
var globalPool *ProviderPool
|
||||
var poolOnce sync.Once
|
||||
|
||||
// GetGlobalPool returns the singleton provider pool instance.
|
||||
func GetGlobalPool() *ProviderPool {
|
||||
poolOnce.Do(func() {
|
||||
globalPool = NewProviderPool(DefaultPoolTTL)
|
||||
})
|
||||
return globalPool
|
||||
}
|
||||
|
||||
// NewProviderPool creates a provider pool with the given TTL for idle providers.
|
||||
func NewProviderPool(ttl time.Duration) *ProviderPool {
|
||||
p := &ProviderPool{
|
||||
providers: make(map[string]*pooledProvider),
|
||||
ttl: ttl,
|
||||
closeCh: make(chan struct{}),
|
||||
}
|
||||
go p.cleanupLoop()
|
||||
return p
|
||||
}
|
||||
|
||||
// Get returns a provider for the model string, creating one if needed.
|
||||
// The returned release function must be called when the provider is no longer
|
||||
// needed. The provider may be reused by subsequent Get calls.
|
||||
func (p *ProviderPool) Get(ctx context.Context, modelString string) (fantasy.LanguageModel, fantasy.ProviderOptions, func(), error) {
|
||||
p.mu.Lock()
|
||||
|
||||
// Check if we have an existing provider.
|
||||
if pp, ok := p.providers[modelString]; ok {
|
||||
pp.refs++
|
||||
pp.lastUsed = time.Now()
|
||||
p.mu.Unlock()
|
||||
return pp.model, pp.providerOpts, func() { p.release(modelString) }, nil
|
||||
}
|
||||
|
||||
p.mu.Unlock()
|
||||
|
||||
// Create a new provider outside the lock.
|
||||
config := &ProviderConfig{ModelString: modelString}
|
||||
result, err := CreateProvider(ctx, config)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// Double-check: another goroutine may have created one while we were unlocked.
|
||||
if pp, ok := p.providers[modelString]; ok {
|
||||
// Close the one we just created and use the existing one.
|
||||
if result.Closer != nil {
|
||||
_ = result.Closer.Close()
|
||||
}
|
||||
pp.refs++
|
||||
pp.lastUsed = time.Now()
|
||||
return pp.model, pp.providerOpts, func() { p.release(modelString) }, nil
|
||||
}
|
||||
|
||||
var closerFn func() error
|
||||
if result.Closer != nil {
|
||||
closerFn = result.Closer.Close
|
||||
}
|
||||
|
||||
pp := &pooledProvider{
|
||||
model: result.Model,
|
||||
closer: closerFn,
|
||||
providerOpts: result.ProviderOptions,
|
||||
created: time.Now(),
|
||||
lastUsed: time.Now(),
|
||||
refs: 1,
|
||||
}
|
||||
p.providers[modelString] = pp
|
||||
|
||||
return pp.model, pp.providerOpts, func() { p.release(modelString) }, nil
|
||||
}
|
||||
|
||||
func (p *ProviderPool) release(modelString string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if pp, ok := p.providers[modelString]; ok {
|
||||
pp.refs--
|
||||
pp.lastUsed = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ProviderPool) cleanupLoop() {
|
||||
ticker := time.NewTicker(p.ttl / 2)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-p.closeCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
p.cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ProviderPool) cleanup() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for key, pp := range p.providers {
|
||||
// Only clean up providers with no active references and past TTL.
|
||||
if pp.refs <= 0 && now.Sub(pp.lastUsed) > p.ttl {
|
||||
if pp.closer != nil {
|
||||
_ = pp.closer()
|
||||
}
|
||||
delete(p.providers, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close shuts down the pool and releases all providers.
|
||||
func (p *ProviderPool) Close() {
|
||||
p.mu.Lock()
|
||||
if p.closed {
|
||||
p.mu.Unlock()
|
||||
return
|
||||
}
|
||||
p.closed = true
|
||||
close(p.closeCh)
|
||||
|
||||
for key, pp := range p.providers {
|
||||
if pp.closer != nil {
|
||||
_ = pp.closer()
|
||||
}
|
||||
delete(p.providers, key)
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
+551
-79
@@ -9,8 +9,11 @@ import (
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
@@ -25,11 +28,30 @@ import (
|
||||
openaisdk "github.com/charmbracelet/openai-go"
|
||||
|
||||
"github.com/mark3labs/kit/internal/auth"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
const (
|
||||
// ClaudeCodePrompt is the required system prompt for OAuth authentication.
|
||||
ClaudeCodePrompt = "You are Claude Code, Anthropic's official CLI for Claude."
|
||||
|
||||
// copilotProviderID is the canonical models.dev provider key. The CLI also
|
||||
// accepts the shorter "copilot" alias for user-facing model strings.
|
||||
copilotProviderID = "github-copilot"
|
||||
// copilotAliasProviderID is the short provider prefix accepted by kit.
|
||||
copilotAliasProviderID = "copilot"
|
||||
// copilotBaseURL is the fallback API URL if the model catalog has no API URL.
|
||||
copilotBaseURL = "https://api.githubcopilot.com"
|
||||
|
||||
// GitHub Copilot currently expects VS Code Copilot Chat client identifiers.
|
||||
// Keep these centralized so they are easy to audit and update when GitHub
|
||||
// changes accepted client metadata.
|
||||
copilotIntegrationID = "vscode-chat"
|
||||
copilotEditorVersion = "vscode/1.104.1"
|
||||
copilotEditorPluginVersion = "copilot-chat/0.31.0"
|
||||
copilotUserAgent = "GitHubCopilotChat/0.31.0"
|
||||
copilotOpenAIIntent = "conversation-agent"
|
||||
copilotGitHubAPIVersion = "2026-01-09"
|
||||
)
|
||||
|
||||
// resolveModelAlias resolves model aliases to their full names using the registry
|
||||
@@ -85,6 +107,7 @@ type ThinkingLevel string
|
||||
|
||||
const (
|
||||
ThinkingOff ThinkingLevel = "off"
|
||||
ThinkingNone ThinkingLevel = "none"
|
||||
ThinkingMinimal ThinkingLevel = "minimal"
|
||||
ThinkingLow ThinkingLevel = "low"
|
||||
ThinkingMedium ThinkingLevel = "medium"
|
||||
@@ -93,12 +116,14 @@ const (
|
||||
|
||||
// ThinkingLevels returns the ordered list of available thinking levels for cycling.
|
||||
func ThinkingLevels() []ThinkingLevel {
|
||||
return []ThinkingLevel{ThinkingOff, ThinkingMinimal, ThinkingLow, ThinkingMedium, ThinkingHigh}
|
||||
return []ThinkingLevel{ThinkingOff, ThinkingNone, ThinkingMinimal, ThinkingLow, ThinkingMedium, ThinkingHigh}
|
||||
}
|
||||
|
||||
// thinkingBudgetTokens returns the token budget for a thinking level, or 0 for "off".
|
||||
// thinkingBudgetTokens returns the token budget for a thinking level, or 0 for "off" or "none".
|
||||
func thinkingBudgetTokens(level ThinkingLevel) int64 {
|
||||
switch level {
|
||||
case ThinkingNone:
|
||||
return 1024
|
||||
case ThinkingMinimal:
|
||||
return 1024
|
||||
case ThinkingLow:
|
||||
@@ -117,6 +142,8 @@ func ThinkingLevelDescription(level ThinkingLevel) string {
|
||||
switch level {
|
||||
case ThinkingOff:
|
||||
return "No reasoning"
|
||||
case ThinkingNone:
|
||||
return "Minimal reasoning (OpenAI 'none')"
|
||||
case ThinkingMinimal:
|
||||
return "Very brief reasoning (~1k tokens)"
|
||||
case ThinkingLow:
|
||||
@@ -133,7 +160,7 @@ func ThinkingLevelDescription(level ThinkingLevel) string {
|
||||
// ParseThinkingLevel converts a string to a ThinkingLevel, defaulting to ThinkingOff.
|
||||
func ParseThinkingLevel(s string) ThinkingLevel {
|
||||
switch ThinkingLevel(s) {
|
||||
case ThinkingMinimal, ThinkingLow, ThinkingMedium, ThinkingHigh:
|
||||
case ThinkingNone, ThinkingMinimal, ThinkingLow, ThinkingMedium, ThinkingHigh:
|
||||
return ThinkingLevel(s)
|
||||
default:
|
||||
return ThinkingOff
|
||||
@@ -159,6 +186,13 @@ type ProviderConfig struct {
|
||||
ThinkingLevel ThinkingLevel
|
||||
DisableCaching bool // Opt-out: set to true to disable automatic prompt caching
|
||||
|
||||
// ConfigStore is the per-instance configuration store used to resolve
|
||||
// "explicitly set" precedence checks (isExplicitlySet), per-model
|
||||
// settings, and right-sizing. When nil, the process-global viper store is
|
||||
// used. Threading a per-Kit store here keeps generation-parameter
|
||||
// precedence isolated between Kit instances in the same process.
|
||||
ConfigStore *viper.Viper
|
||||
|
||||
// ProgressReaderFunc, when set, wraps an io.Reader with progress display
|
||||
// for long operations like Ollama model pulls. The returned io.ReadCloser
|
||||
// must be closed when done. When nil, the raw reader is consumed directly
|
||||
@@ -200,6 +234,20 @@ func ParseModelString(modelString string) (provider, model string, err error) {
|
||||
return "", "", fmt.Errorf("invalid model format %q: expected provider/model (e.g. anthropic/claude-sonnet-4-5)", modelString)
|
||||
}
|
||||
|
||||
// isCopilotProvider reports whether provider is the canonical catalog key or
|
||||
// the user-facing shorthand alias.
|
||||
func isCopilotProvider(provider string) bool {
|
||||
return provider == copilotAliasProviderID || provider == copilotProviderID
|
||||
}
|
||||
|
||||
// catalogProviderID maps supported provider aliases to their models.dev keys.
|
||||
func catalogProviderID(provider string) string {
|
||||
if isCopilotProvider(provider) {
|
||||
return copilotProviderID
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
// CreateProvider creates a fantasy LanguageModel based on the provider configuration.
|
||||
// Model metadata is looked up from the models.dev database for cost tracking and
|
||||
// capability detection, but unknown models are passed through to the provider
|
||||
@@ -207,8 +255,10 @@ func ParseModelString(modelString string) (provider, model string, err error) {
|
||||
//
|
||||
// Native providers: anthropic, openai, google, ollama, azure, google-vertex-anthropic,
|
||||
// openrouter, bedrock, vercel.
|
||||
// Any provider in models.dev with an api URL or openai-compatible npm package
|
||||
// is auto-routed through fantasy's openaicompat provider.
|
||||
// Any other provider in models.dev is auto-routed by wire protocol: its npm
|
||||
// package (or per-model override) selects the OpenAI, Anthropic, or Google
|
||||
// transport, using the provider's api URL as the base. Providers with an api
|
||||
// URL but an unrecognized npm package fall back to the OpenAI-compatible wire.
|
||||
func CreateProvider(ctx context.Context, config *ProviderConfig) (*ProviderResult, error) {
|
||||
provider, modelName, err := ParseModelString(config.ModelString)
|
||||
if err != nil {
|
||||
@@ -221,17 +271,30 @@ func CreateProvider(ctx context.Context, config *ProviderConfig) (*ProviderResul
|
||||
}
|
||||
|
||||
registry := GetGlobalRegistry()
|
||||
lookupProvider := catalogProviderID(provider)
|
||||
|
||||
// Look up model metadata (advisory, not blocking).
|
||||
// Look up model metadata (advisory for most providers, strict for Copilot).
|
||||
// When the model is known we validate config limits and print
|
||||
// suggestions on likely typos; when unknown we let the provider
|
||||
// API be the authority.
|
||||
modelInfo := registry.LookupModel(provider, modelName)
|
||||
if modelInfo == nil && provider != "ollama" && config.ProviderURL == "" {
|
||||
// API be the authority except for Copilot, whose non-GPT catalog entries
|
||||
// require unsupported wire protocols.
|
||||
modelInfo := registry.LookupModel(lookupProvider, modelName)
|
||||
if isCopilotProvider(provider) {
|
||||
providerInfo := registry.GetProviderInfo(copilotProviderID)
|
||||
if providerInfo == nil {
|
||||
return nil, fmt.Errorf("unsupported provider: %s (not found in model database)", copilotProviderID)
|
||||
}
|
||||
if modelInfo == nil {
|
||||
if suggestions := registry.SuggestModels(copilotProviderID, modelName); len(suggestions) > 0 {
|
||||
return nil, fmt.Errorf("model %q not found for provider %s. Did you mean one of: %s", modelName, copilotProviderID, strings.Join(suggestions, ", "))
|
||||
}
|
||||
return nil, fmt.Errorf("model %q not found for provider %s", modelName, copilotProviderID)
|
||||
}
|
||||
} else if modelInfo == nil && provider != "ollama" && config.ProviderURL == "" {
|
||||
// Model not in database — warn with suggestions but don't block.
|
||||
if suggestions := registry.SuggestModels(provider, modelName); len(suggestions) > 0 {
|
||||
if suggestions := registry.SuggestModels(lookupProvider, modelName); len(suggestions) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "Warning: model %q not found in model database for provider %s. Similar models: %s\n",
|
||||
modelName, provider, strings.Join(suggestions, ", "))
|
||||
modelName, lookupProvider, strings.Join(suggestions, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,6 +314,11 @@ func CreateProvider(ctx context.Context, config *ProviderConfig) (*ProviderResul
|
||||
// via CLI flag or global config.
|
||||
ApplyModelSettings(config, modelInfo)
|
||||
|
||||
// Auto-raise MaxTokens toward the model's known output ceiling when the
|
||||
// user hasn't explicitly set --max-tokens and no per-model override
|
||||
// applied. Runs after ApplyModelSettings so explicit modelSettings win.
|
||||
rightSizeMaxTokens(config, modelInfo)
|
||||
|
||||
// Create the base provider
|
||||
var result *ProviderResult
|
||||
var createErr error
|
||||
@@ -260,17 +328,21 @@ func CreateProvider(ctx context.Context, config *ProviderConfig) (*ProviderResul
|
||||
result, createErr = createAnthropicProvider(ctx, config, modelName)
|
||||
case "openai":
|
||||
result, createErr = createOpenAIProvider(ctx, config, modelName)
|
||||
case "copilot", "github-copilot":
|
||||
result, createErr = createCopilotProvider(ctx, config, modelName)
|
||||
case "google", "gemini":
|
||||
result, createErr = createGoogleProvider(ctx, config, modelName)
|
||||
case "ollama":
|
||||
result, createErr = createOllamaProvider(ctx, config, modelName)
|
||||
case "azure":
|
||||
case "azure", "azure-cognitive-services":
|
||||
result, createErr = createAzureProvider(ctx, config, modelName)
|
||||
case "google-vertex-anthropic":
|
||||
result, createErr = createVertexAnthropicProvider(ctx, config, modelName)
|
||||
case "google-vertex":
|
||||
result, createErr = createGoogleVertexProvider(ctx, config, modelName)
|
||||
case "openrouter":
|
||||
result, createErr = createOpenRouterProvider(ctx, config, modelName)
|
||||
case "bedrock":
|
||||
case "bedrock", "amazon-bedrock":
|
||||
result, createErr = createBedrockProvider(ctx, config, modelName)
|
||||
case "vercel":
|
||||
result, createErr = createVercelProvider(ctx, config, modelName)
|
||||
@@ -295,9 +367,18 @@ func CreateProvider(ctx context.Context, config *ProviderConfig) (*ProviderResul
|
||||
// Only add cache options for providers that don't already have
|
||||
// options set, to avoid type conflicts (e.g., Anthropic has
|
||||
// different types for regular options vs cache control options).
|
||||
for k, v := range cacheOpts {
|
||||
if _, exists := result.ProviderOptions[k]; !exists {
|
||||
result.ProviderOptions[k] = v
|
||||
//
|
||||
// For OpenAI Responses API models, we skip merging entirely because
|
||||
// ResponsesProviderOptions and ProviderOptions are incompatible types.
|
||||
skipMerge := false
|
||||
if provider == "openai" && openai.IsResponsesModel(modelName) {
|
||||
skipMerge = true
|
||||
}
|
||||
if !skipMerge {
|
||||
for k, v := range cacheOpts {
|
||||
if _, exists := result.ProviderOptions[k]; !exists {
|
||||
result.ProviderOptions[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -308,44 +389,100 @@ func CreateProvider(ctx context.Context, config *ProviderConfig) (*ProviderResul
|
||||
|
||||
// autoRouteProvider attempts to create a provider by looking up its npm package
|
||||
// in the models.dev database and routing through the appropriate fantasy provider.
|
||||
// For openai-compatible providers, it uses the api URL from models.dev.
|
||||
// Models may have a provider override that specifies a different npm package than
|
||||
// the provider's default (e.g., opencode's claude-opus-4-6 uses @ai-sdk/anthropic).
|
||||
// It routes on wire protocol (openai, anthropic, google) rather than per-npm
|
||||
// provider name: fantasy implements three native wire protocols, and every other
|
||||
// entry in its providers/ tree is a thin wrapper around one of them. Using the
|
||||
// provider's api URL from models.dev as the base URL, any proxy that re-flavors
|
||||
// one of these protocols (e.g. opencode's Gemini routes) Just Works.
|
||||
//
|
||||
// Models may carry a provider override that specifies a different npm package
|
||||
// than the provider's default (e.g. opencode's claude-* uses @ai-sdk/anthropic
|
||||
// and its gemini-* uses @ai-sdk/google), which is resolved first.
|
||||
func autoRouteProvider(ctx context.Context, config *ProviderConfig, provider, modelName string, registry *ModelsRegistry) (*ProviderResult, error) {
|
||||
providerInfo := registry.GetProviderInfo(provider)
|
||||
if providerInfo == nil {
|
||||
return nil, fmt.Errorf("unsupported provider: %s (not found in model database)", provider)
|
||||
}
|
||||
|
||||
// Check for model-specific provider override
|
||||
// Resolve npm: per-model override > provider default.
|
||||
npmPackage := providerInfo.NPM
|
||||
if modelInfo := registry.LookupModel(provider, modelName); modelInfo != nil && modelInfo.ProviderNPM != "" {
|
||||
npmPackage = modelInfo.ProviderNPM
|
||||
}
|
||||
|
||||
// Determine the LLM provider for this npm package
|
||||
llmProvider := npmToLLMProvider[npmPackage]
|
||||
if llmProvider == "" && providerInfo.API != "" {
|
||||
// Unknown npm but has API URL → route through openaicompat
|
||||
llmProvider = "openaicompat"
|
||||
wire, known := npmToWireProtocol[npmPackage]
|
||||
if !known {
|
||||
// Unknown npm but the provider has an API URL → assume OpenAI-compatible.
|
||||
// (Preserves the long-standing "any provider in models.dev with an api URL
|
||||
// is auto-routed through openaicompat" behaviour.)
|
||||
if providerInfo.API == "" {
|
||||
return nil, fmt.Errorf(
|
||||
"cannot auto-route provider %s: npm package %q has no known wire protocol "+
|
||||
"and the registry has no API URL (use --provider-url to override)",
|
||||
provider, npmPackage,
|
||||
)
|
||||
}
|
||||
wire = wireOpenAI
|
||||
}
|
||||
|
||||
switch llmProvider {
|
||||
case "openaicompat":
|
||||
return createAutoRoutedOpenAICompatProvider(ctx, config, modelName, providerInfo)
|
||||
case "anthropic":
|
||||
if config.ProviderURL == "" && providerInfo.API != "" {
|
||||
// All three wires use the provider's API URL from models.dev as the base.
|
||||
// When the registry has none, fall back to the SDK's hard-coded default for
|
||||
// this npm package (covers groq, cerebras, mistral, x.ai, etc. — providers
|
||||
// whose JS SDK ships a built-in baseURL that models.dev doesn't restate).
|
||||
if config.ProviderURL == "" {
|
||||
if providerInfo.API != "" {
|
||||
config.ProviderURL = providerInfo.API
|
||||
} else if defaultURL, ok := sdkDefaultBaseURL[npmPackage]; ok {
|
||||
config.ProviderURL = defaultURL
|
||||
providerInfo.API = defaultURL // for downstream helpers that read info.API
|
||||
}
|
||||
return createAutoRoutedAnthropicProvider(ctx, config, modelName, providerInfo)
|
||||
case "openai":
|
||||
if config.ProviderURL == "" && providerInfo.API != "" {
|
||||
config.ProviderURL = providerInfo.API
|
||||
}
|
||||
return createAutoRoutedOpenAIProvider(ctx, config, modelName, providerInfo)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported provider: %s (npm: %s has no LLM provider mapping)", provider, npmPackage)
|
||||
}
|
||||
|
||||
// Provider templates a runtime account/region/deployment segment into the
|
||||
// URL (cloudflare-ai-gateway, databricks, snowflake-cortex, gitlab,
|
||||
// sap-ai-core). Resolve via environment variables, or surface a targeted
|
||||
// error pointing the user at the right knobs.
|
||||
if resolved, err := resolveTemplatedAPIURL(config.ProviderURL, providerInfo); err != nil {
|
||||
return nil, err
|
||||
} else if resolved != "" {
|
||||
config.ProviderURL = resolved
|
||||
providerInfo.API = resolved
|
||||
}
|
||||
|
||||
switch wire {
|
||||
case wireOpenAI:
|
||||
// The native OpenAI SDK package (@ai-sdk/openai) speaks the Responses
|
||||
// API; openai-compatible proxies (and unknown-npm fallbacks) use the
|
||||
// chat-completions wire via fantasy's openaicompat provider.
|
||||
if npmPackage == "@ai-sdk/openai" {
|
||||
return createAutoRoutedOpenAIProvider(ctx, config, modelName, providerInfo)
|
||||
}
|
||||
return createAutoRoutedOpenAICompatProvider(ctx, config, modelName, providerInfo)
|
||||
case wireAnthropic:
|
||||
return createAutoRoutedAnthropicProvider(ctx, config, modelName, providerInfo)
|
||||
case wireGoogle:
|
||||
return createAutoRoutedGoogleProvider(ctx, config, modelName, providerInfo)
|
||||
default:
|
||||
return nil, fmt.Errorf("internal error: unknown wire protocol for provider %s (npm: %s)", provider, npmPackage)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveAutoRouteAPIKey looks up the API key for an auto-routed provider,
|
||||
// returning a uniform error message when none can be resolved.
|
||||
func resolveAutoRouteAPIKey(config *ProviderConfig, info *ProviderInfo) (string, error) {
|
||||
apiKey := resolveAPIKey(config.ProviderAPIKey, info.Env)
|
||||
if apiKey == "" {
|
||||
return "", fmt.Errorf("%s API key not provided. Use --provider-api-key or set %s",
|
||||
info.Name, strings.Join(info.Env, " / "))
|
||||
}
|
||||
return apiKey, nil
|
||||
}
|
||||
|
||||
// wrapProviderErr produces the uniform "failed to create X provider/model: %w"
|
||||
// error wrap used by every createXxxProvider path. kind is typically
|
||||
// "provider" or "model".
|
||||
func wrapProviderErr(name, kind string, err error) error {
|
||||
return fmt.Errorf("failed to create %s %s: %w", name, kind, err)
|
||||
}
|
||||
|
||||
// createAutoRoutedOpenAICompatProvider creates an openaicompat provider using
|
||||
@@ -359,10 +496,9 @@ func createAutoRoutedOpenAICompatProvider(ctx context.Context, config *ProviderC
|
||||
return nil, fmt.Errorf("provider %s requires --provider-url (no API URL in database)", info.ID)
|
||||
}
|
||||
|
||||
apiKey := resolveAPIKey(config.ProviderAPIKey, info.Env)
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("%s API key not provided. Use --provider-api-key or set %s",
|
||||
info.Name, strings.Join(info.Env, " / "))
|
||||
apiKey, err := resolveAutoRouteAPIKey(config, info)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var opts []openaicompat.Option
|
||||
@@ -376,12 +512,12 @@ func createAutoRoutedOpenAICompatProvider(ctx context.Context, config *ProviderC
|
||||
|
||||
p, err := openaicompat.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create %s provider: %w", info.Name, err)
|
||||
return nil, wrapProviderErr(info.Name, "provider", err)
|
||||
}
|
||||
|
||||
model, err := p.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create %s model: %w", info.Name, err)
|
||||
return nil, wrapProviderErr(info.Name, "model", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
@@ -392,10 +528,9 @@ func createAutoRoutedOpenAICompatProvider(ctx context.Context, config *ProviderC
|
||||
func createAutoRoutedAnthropicProvider(ctx context.Context, config *ProviderConfig, modelName string, info *ProviderInfo) (*ProviderResult, error) {
|
||||
clearConflictingAnthropicSamplingParams(config)
|
||||
|
||||
apiKey := resolveAPIKey(config.ProviderAPIKey, info.Env)
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("%s API key not provided. Use --provider-api-key or set %s",
|
||||
info.Name, strings.Join(info.Env, " / "))
|
||||
apiKey, err := resolveAutoRouteAPIKey(config, info)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var opts []anthropic.Option
|
||||
@@ -414,12 +549,12 @@ func createAutoRoutedAnthropicProvider(ctx context.Context, config *ProviderConf
|
||||
|
||||
p, err := anthropic.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create %s provider: %w", info.Name, err)
|
||||
return nil, wrapProviderErr(info.Name, "provider", err)
|
||||
}
|
||||
|
||||
model, err := p.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create %s model: %w", info.Name, err)
|
||||
return nil, wrapProviderErr(info.Name, "model", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
@@ -428,10 +563,9 @@ func createAutoRoutedAnthropicProvider(ctx context.Context, config *ProviderConf
|
||||
// createAutoRoutedOpenAIProvider creates an openai provider for
|
||||
// third-party providers with openai-compatible APIs.
|
||||
func createAutoRoutedOpenAIProvider(ctx context.Context, config *ProviderConfig, modelName string, info *ProviderInfo) (*ProviderResult, error) {
|
||||
apiKey := resolveAPIKey(config.ProviderAPIKey, info.Env)
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("%s API key not provided. Use --provider-api-key or set %s",
|
||||
info.Name, strings.Join(info.Env, " / "))
|
||||
apiKey, err := resolveAutoRouteAPIKey(config, info)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var opts []openai.Option
|
||||
@@ -448,12 +582,12 @@ func createAutoRoutedOpenAIProvider(ctx context.Context, config *ProviderConfig,
|
||||
|
||||
p, err := openai.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create %s provider: %w", info.Name, err)
|
||||
return nil, wrapProviderErr(info.Name, "provider", err)
|
||||
}
|
||||
|
||||
model, err := p.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create %s model: %w", info.Name, err)
|
||||
return nil, wrapProviderErr(info.Name, "model", err)
|
||||
}
|
||||
|
||||
providerOpts := buildOpenAIProviderOptions(config, modelName)
|
||||
@@ -461,6 +595,114 @@ func createAutoRoutedOpenAIProvider(ctx context.Context, config *ProviderConfig,
|
||||
return &ProviderResult{Model: model, ProviderOptions: providerOpts}, nil
|
||||
}
|
||||
|
||||
// createAutoRoutedGoogleProvider creates a Google (Gemini) provider for
|
||||
// third-party providers that expose a Gemini-compatible API (e.g. opencode's
|
||||
// Gemini routes, which carry an @ai-sdk/google per-model override).
|
||||
//
|
||||
// The underlying genai SDK always injects its own API version segment
|
||||
// ("v1beta") between the base URL and the resource path. When the proxy's
|
||||
// base URL from models.dev already carries a version segment (e.g. opencode's
|
||||
// https://opencode.ai/zen/v1), that produces a doubled ".../v1/v1beta/..."
|
||||
// path that the proxy rejects. In that case we install a transport that
|
||||
// strips the injected segment so the proxy's own version is used.
|
||||
func createAutoRoutedGoogleProvider(ctx context.Context, config *ProviderConfig, modelName string, info *ProviderInfo) (*ProviderResult, error) {
|
||||
apiKey, err := resolveAutoRouteAPIKey(config, info)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
opts := []google.Option{
|
||||
google.WithGeminiAPIKey(apiKey),
|
||||
google.WithName(info.ID),
|
||||
}
|
||||
|
||||
if config.ProviderURL != "" {
|
||||
opts = append(opts, google.WithBaseURL(config.ProviderURL))
|
||||
}
|
||||
|
||||
// Decide whether the genai-injected version segment needs stripping.
|
||||
var httpClient *http.Client
|
||||
if basePath := versionedBasePath(config.ProviderURL); basePath != "" {
|
||||
httpClient = newGeminiProxyHTTPClient(basePath, config.TLSSkipVerify)
|
||||
} else if config.TLSSkipVerify {
|
||||
httpClient = createHTTPClientWithTLSConfig(true)
|
||||
}
|
||||
if httpClient != nil {
|
||||
opts = append(opts, google.WithHTTPClient(httpClient))
|
||||
}
|
||||
|
||||
p, err := google.New(opts...)
|
||||
if err != nil {
|
||||
return nil, wrapProviderErr(info.Name, "provider", err)
|
||||
}
|
||||
|
||||
model, err := p.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, wrapProviderErr(info.Name, "model", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
}
|
||||
|
||||
// versionSegmentRe matches a trailing API version segment in a URL path,
|
||||
// e.g. "/v1", "/v1beta", "/v1beta1", "/v2alpha".
|
||||
var versionSegmentRe = regexp.MustCompile(`/v\d+(?:beta\d*|alpha\d*)?$`)
|
||||
|
||||
// versionedBasePath returns the path component of rawURL when that path ends
|
||||
// with an API version segment (e.g. opencode's ".../zen/v1" → "/zen/v1").
|
||||
// It returns "" when rawURL is empty, unparseable, or has no version suffix
|
||||
// — in which case the genai SDK's default version injection is correct and
|
||||
// no rewriting is needed.
|
||||
func versionedBasePath(rawURL string) string {
|
||||
if rawURL == "" {
|
||||
return ""
|
||||
}
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
path := strings.TrimSuffix(u.Path, "/")
|
||||
if versionSegmentRe.MatchString(path) {
|
||||
return path
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// newGeminiProxyHTTPClient builds an HTTP client whose transport strips the
|
||||
// genai-injected version segment ("v1beta"/"v1beta1") that directly follows
|
||||
// basePath, collapsing "{basePath}/v1beta/..." back to "{basePath}/...".
|
||||
func newGeminiProxyHTTPClient(basePath string, skipVerify bool) *http.Client {
|
||||
var base http.RoundTripper
|
||||
if skipVerify {
|
||||
base = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
|
||||
} else {
|
||||
base = http.DefaultTransport
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: &geminiProxyTransport{base: base, basePath: basePath},
|
||||
}
|
||||
}
|
||||
|
||||
// geminiProxyTransport removes the redundant API version segment that the
|
||||
// genai SDK injects after a proxy base URL that already carries its own
|
||||
// version segment.
|
||||
type geminiProxyTransport struct {
|
||||
base http.RoundTripper
|
||||
basePath string
|
||||
}
|
||||
|
||||
func (t *geminiProxyTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
for _, injected := range []string{"/v1beta1", "/v1beta"} {
|
||||
prefix := t.basePath + injected + "/"
|
||||
if strings.HasPrefix(req.URL.Path, prefix) {
|
||||
newReq := req.Clone(req.Context())
|
||||
newReq.URL.Path = t.basePath + strings.TrimPrefix(req.URL.Path, t.basePath+injected)
|
||||
return t.base.RoundTrip(newReq)
|
||||
}
|
||||
}
|
||||
return t.base.RoundTrip(req)
|
||||
}
|
||||
|
||||
// resolveAPIKey returns the first non-empty API key from the explicit key
|
||||
// or the environment variables.
|
||||
func resolveAPIKey(explicitKey string, envVars []string) string {
|
||||
@@ -489,6 +731,37 @@ func validateModelConfig(config *ProviderConfig, modelInfo *ModelInfo) {
|
||||
}
|
||||
}
|
||||
|
||||
// defaultRightSizeCap bounds auto-raised MaxTokens so that we don't silently
|
||||
// allocate enormous output budgets for models with very high ceilings (e.g.
|
||||
// Devstral at 262144, Mistral at 128000). Users who genuinely want more can
|
||||
// pass --max-tokens explicitly or set modelSettings[...].maxTokens in config.
|
||||
const defaultRightSizeCap = 32768
|
||||
|
||||
// rightSizeMaxTokens raises config.MaxTokens toward the model's known output
|
||||
// ceiling when:
|
||||
// - the user has not explicitly set --max-tokens (or the KIT_MAX_TOKENS env
|
||||
// var, or the top-level max-tokens key in config.yaml), AND
|
||||
// - no per-model override already bumped MaxTokens (ApplyModelSettings runs
|
||||
// before this function), AND
|
||||
// - modelInfo.Limit.Output is known and larger than the current MaxTokens.
|
||||
//
|
||||
// The raised value is capped at defaultRightSizeCap to keep accidental
|
||||
// allocations reasonable on very-large-output models. This prevents the
|
||||
// common "ghost" where the agent's reply is silently truncated at the 8192
|
||||
// default even though the selected model supports 64k or 262k output tokens.
|
||||
func rightSizeMaxTokens(config *ProviderConfig, modelInfo *ModelInfo) {
|
||||
if modelInfo == nil || modelInfo.Limit.Output <= 0 {
|
||||
return
|
||||
}
|
||||
if isExplicitlySet(config.ConfigStore, "max-tokens") {
|
||||
return
|
||||
}
|
||||
target := min(modelInfo.Limit.Output, defaultRightSizeCap)
|
||||
if config.MaxTokens < target {
|
||||
config.MaxTokens = target
|
||||
}
|
||||
}
|
||||
|
||||
// clearConflictingAnthropicSamplingParams ensures that temperature and top_p are
|
||||
// not both sent to the Anthropic API, which rejects requests containing both.
|
||||
// When both are set (typically from defaults), top_p is cleared so that
|
||||
@@ -535,6 +808,8 @@ func buildOpenAIProviderOptions(config *ProviderConfig, modelName string) fantas
|
||||
// Returns nil for ThinkingOff (use the model's default).
|
||||
func thinkingLevelToReasoningEffort(level ThinkingLevel) *openai.ReasoningEffort {
|
||||
switch level {
|
||||
case ThinkingNone:
|
||||
return new(openai.ReasoningEffortNone)
|
||||
case ThinkingMinimal:
|
||||
return new(openai.ReasoningEffortMinimal)
|
||||
case ThinkingLow:
|
||||
@@ -548,6 +823,56 @@ func thinkingLevelToReasoningEffort(level ThinkingLevel) *openai.ReasoningEffort
|
||||
}
|
||||
}
|
||||
|
||||
// IsValidThinkingLevelForModel checks if a thinking level is valid for the given
|
||||
// model. Some OpenAI models like gpt-5.4 don't support "minimal" and require
|
||||
// "none" instead.
|
||||
func IsValidThinkingLevelForModel(level ThinkingLevel, modelName string) bool {
|
||||
if level == ThinkingOff {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if this is an OpenAI model that doesn't support "minimal"
|
||||
// gpt-5.4 and newer gpt-5.x models use "none" instead of "minimal"
|
||||
if level == ThinkingMinimal {
|
||||
if strings.Contains(modelName, "gpt-5.4") ||
|
||||
strings.Contains(modelName, "gpt-5-pro") ||
|
||||
strings.Contains(modelName, "gpt-5-chat") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is an OpenAI model that doesn't support "none"
|
||||
// Older gpt-5 models only support "minimal", not "none"
|
||||
if level == ThinkingNone {
|
||||
if strings.Contains(modelName, "gpt-5") &&
|
||||
!strings.Contains(modelName, "gpt-5.4") &&
|
||||
!strings.Contains(modelName, "gpt-5-pro") &&
|
||||
!strings.Contains(modelName, "gpt-5-chat") {
|
||||
// Older gpt-5 models might not support "none"
|
||||
// They only added "none" support in newer versions
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// All other levels are generally valid for reasoning models
|
||||
return true
|
||||
}
|
||||
|
||||
// SuggestThinkingLevelFallback returns a recommended fallback level when the
|
||||
// requested level is not valid for the model. Returns ThinkingOff if no
|
||||
// suitable fallback exists.
|
||||
func SuggestThinkingLevelFallback(level ThinkingLevel, modelName string) ThinkingLevel {
|
||||
if level == ThinkingMinimal && !IsValidThinkingLevelForModel(level, modelName) {
|
||||
// For models that don't support "minimal", suggest "none" (~same token budget)
|
||||
return ThinkingNone
|
||||
}
|
||||
if level == ThinkingNone && !IsValidThinkingLevelForModel(level, modelName) {
|
||||
// For models that don't support "none", suggest "minimal" (~same token budget)
|
||||
return ThinkingMinimal
|
||||
}
|
||||
return ThinkingOff
|
||||
}
|
||||
|
||||
// buildAnthropicProviderOptions returns fantasy.ProviderOptions configured for
|
||||
// Anthropic models with extended thinking. When thinking is enabled, it sets
|
||||
// SendReasoning to true and configures the thinking budget. For thinking-off
|
||||
@@ -607,7 +932,7 @@ func createAnthropicProvider(ctx context.Context, config *ProviderConfig, modelN
|
||||
}
|
||||
|
||||
// Handle OAuth vs API key authentication
|
||||
if strings.HasPrefix(source, "stored OAuth") {
|
||||
if source == auth.CredentialSourceOAuth {
|
||||
httpClient := createOAuthHTTPClient(apiKey, config.TLSSkipVerify)
|
||||
opts = append(opts, anthropic.WithHTTPClient(httpClient))
|
||||
// Note: For OAuth, the API key is set as a placeholder; the transport handles auth
|
||||
@@ -617,12 +942,12 @@ func createAnthropicProvider(ctx context.Context, config *ProviderConfig, modelN
|
||||
|
||||
provider, err := anthropic.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Anthropic provider: %w", err)
|
||||
return nil, wrapProviderErr("Anthropic", "provider", err)
|
||||
}
|
||||
|
||||
model, err := provider.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Anthropic model: %w", err)
|
||||
return nil, wrapProviderErr("Anthropic", "model", err)
|
||||
}
|
||||
|
||||
// Build provider options for extended thinking (reasoning budget).
|
||||
@@ -659,12 +984,12 @@ func createVertexAnthropicProvider(ctx context.Context, config *ProviderConfig,
|
||||
|
||||
provider, err := anthropic.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Vertex Anthropic provider: %w", err)
|
||||
return nil, wrapProviderErr("Vertex Anthropic", "provider", err)
|
||||
}
|
||||
|
||||
model, err := provider.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Vertex Anthropic model: %w", err)
|
||||
return nil, wrapProviderErr("Vertex Anthropic", "model", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
@@ -732,12 +1057,12 @@ func createOpenAIProvider(ctx context.Context, config *ProviderConfig, modelName
|
||||
|
||||
provider, err := openai.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OpenAI provider: %w", err)
|
||||
return nil, wrapProviderErr("OpenAI", "provider", err)
|
||||
}
|
||||
|
||||
model, err := provider.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OpenAI model: %w", err)
|
||||
return nil, wrapProviderErr("OpenAI", "model", err)
|
||||
}
|
||||
|
||||
// Build provider options for OpenAI Responses API reasoning models.
|
||||
@@ -746,6 +1071,72 @@ func createOpenAIProvider(ctx context.Context, config *ProviderConfig, modelName
|
||||
return &ProviderResult{Model: model, ProviderOptions: providerOpts}, nil
|
||||
}
|
||||
|
||||
// createCopilotProvider builds a GitHub Copilot provider through fantasy's
|
||||
// OpenAI-compatible provider. The catalog key is github-copilot, but the public
|
||||
// model prefix may be either copilot/ or github-copilot/.
|
||||
//
|
||||
// Only gpt-* Copilot models are enabled here. The catalog also lists Claude and
|
||||
// Gemini Copilot models, but those require different wire protocols and must be
|
||||
// routed explicitly before they can be safely accepted.
|
||||
func createCopilotProvider(ctx context.Context, config *ProviderConfig, modelName string) (*ProviderResult, error) {
|
||||
if !strings.HasPrefix(modelName, "gpt-") {
|
||||
return nil, fmt.Errorf("GitHub Copilot model %q is not supported yet: only gpt-* models use the OpenAI-compatible protocol", modelName)
|
||||
}
|
||||
|
||||
cm, err := auth.NewCredentialManager()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize credential manager: %w", err)
|
||||
}
|
||||
|
||||
token, err := cm.GetValidCopilotAccessTokenContext(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GitHub Copilot credentials not available. Use 'kit auth login copilot': %w", err)
|
||||
}
|
||||
|
||||
expiresAt := int64(0)
|
||||
if creds, err := cm.GetCopilotCredentials(); err == nil && creds != nil && creds.CopilotAccessToken == token {
|
||||
expiresAt = creds.ExpiresAt
|
||||
}
|
||||
|
||||
baseURL := copilotBaseURL
|
||||
if providerInfo := GetGlobalRegistry().GetProviderInfo(copilotProviderID); providerInfo != nil && providerInfo.API != "" {
|
||||
baseURL = providerInfo.API
|
||||
}
|
||||
if config.ProviderURL != "" {
|
||||
baseURL = config.ProviderURL
|
||||
}
|
||||
|
||||
opts := []openai.Option{
|
||||
openai.WithName(copilotAliasProviderID),
|
||||
openai.WithBaseURL(baseURL),
|
||||
openai.WithAPIKey(token),
|
||||
openai.WithHTTPClient(createCopilotHTTPClient(token, expiresAt, config.TLSSkipVerify)),
|
||||
openai.WithUseResponsesAPI(),
|
||||
openai.WithResponsesAPIFunc(copilotUsesResponsesAPI),
|
||||
openai.WithObjectMode(fantasy.ObjectModeTool),
|
||||
}
|
||||
|
||||
provider, err := openai.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create GitHub Copilot provider: %w", err)
|
||||
}
|
||||
|
||||
model, err := provider.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create GitHub Copilot model: %w", err)
|
||||
}
|
||||
|
||||
providerOpts := buildOpenAIProviderOptions(config, modelName)
|
||||
|
||||
return &ProviderResult{Model: model, ProviderOptions: providerOpts}, nil
|
||||
}
|
||||
|
||||
// copilotUsesResponsesAPI selects the OpenAI Responses API for Copilot models
|
||||
// known to support it. Non-gpt models are rejected before provider creation.
|
||||
func copilotUsesResponsesAPI(modelID string) bool {
|
||||
return strings.HasPrefix(modelID, "gpt-5")
|
||||
}
|
||||
|
||||
// createOpenAICodexProvider creates a provider for ChatGPT/Codex OAuth tokens.
|
||||
// Uses the chatgpt.com/backend-api/codex endpoint with special headers.
|
||||
func createOpenAICodexProvider(ctx context.Context, config *ProviderConfig, modelName, token, accountID string) (*ProviderResult, error) {
|
||||
@@ -773,12 +1164,12 @@ func createOpenAICodexProvider(ctx context.Context, config *ProviderConfig, mode
|
||||
|
||||
provider, err := openai.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OpenAI Codex provider: %w", err)
|
||||
return nil, wrapProviderErr("OpenAI Codex", "provider", err)
|
||||
}
|
||||
|
||||
model, err := provider.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OpenAI Codex model: %w", err)
|
||||
return nil, wrapProviderErr("OpenAI Codex", "model", err)
|
||||
}
|
||||
|
||||
providerOpts := buildCodexProviderOptions(config, modelName)
|
||||
@@ -875,6 +1266,87 @@ func (t *codexTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return t.base.RoundTrip(newReq)
|
||||
}
|
||||
|
||||
// createCopilotHTTPClient returns an HTTP client that injects Copilot-specific
|
||||
// authorization and client metadata headers. The token and expiry are cached in
|
||||
// the transport so streaming requests do not hit credentials.json on every
|
||||
// RoundTrip; the credential manager is consulted only near expiry.
|
||||
func createCopilotHTTPClient(token string, expiresAt int64, skipVerify bool) *http.Client {
|
||||
var base http.RoundTripper
|
||||
if skipVerify {
|
||||
base = &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
base = http.DefaultTransport
|
||||
}
|
||||
|
||||
return &http.Client{
|
||||
Transport: &copilotTransport{
|
||||
base: base,
|
||||
token: token,
|
||||
expiresAt: expiresAt,
|
||||
},
|
||||
Timeout: 120 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// copilotTransport decorates requests for api.githubcopilot.com.
|
||||
//
|
||||
// It owns a cached Copilot access token. When the token is still valid, the hot
|
||||
// path is in-memory only. Near expiry it refreshes through CredentialManager,
|
||||
// which updates both the cache here and credentials.json.
|
||||
type copilotTransport struct {
|
||||
base http.RoundTripper
|
||||
token string
|
||||
expiresAt int64
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (t *copilotTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
token := t.cachedToken(req.Context())
|
||||
|
||||
newReq := req.Clone(req.Context())
|
||||
newReq.Header.Set("Authorization", "Bearer "+token)
|
||||
newReq.Header.Set("Copilot-Integration-Id", copilotIntegrationID)
|
||||
newReq.Header.Set("Editor-Version", copilotEditorVersion)
|
||||
newReq.Header.Set("Editor-Plugin-Version", copilotEditorPluginVersion)
|
||||
newReq.Header.Set("Openai-Intent", copilotOpenAIIntent)
|
||||
newReq.Header.Set("User-Agent", copilotUserAgent)
|
||||
newReq.Header.Set("X-GitHub-Api-Version", copilotGitHubAPIVersion)
|
||||
|
||||
return t.base.RoundTrip(newReq)
|
||||
}
|
||||
|
||||
// cachedToken returns the cached token unless it is within the five-minute
|
||||
// refresh window. Refresh errors fall back to the last token so the request can
|
||||
// surface any authoritative auth failure from the Copilot API.
|
||||
func (t *copilotTransport) cachedToken(ctx context.Context) string {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
if t.expiresAt == 0 || time.Now().Unix() < t.expiresAt-300 {
|
||||
return t.token
|
||||
}
|
||||
|
||||
cm, err := auth.NewCredentialManager()
|
||||
if err != nil {
|
||||
return t.token
|
||||
}
|
||||
|
||||
fresh, err := cm.GetValidCopilotAccessTokenContext(ctx)
|
||||
if err != nil || fresh == "" {
|
||||
return t.token
|
||||
}
|
||||
|
||||
t.token = fresh
|
||||
if creds, err := cm.GetCopilotCredentials(); err == nil && creds != nil && creds.CopilotAccessToken == fresh {
|
||||
t.expiresAt = creds.ExpiresAt
|
||||
}
|
||||
return t.token
|
||||
}
|
||||
|
||||
func createGoogleProvider(ctx context.Context, config *ProviderConfig, modelName string) (*ProviderResult, error) {
|
||||
apiKey := firstNonEmpty(
|
||||
config.ProviderAPIKey,
|
||||
@@ -891,12 +1363,12 @@ func createGoogleProvider(ctx context.Context, config *ProviderConfig, modelName
|
||||
|
||||
provider, err := google.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Google provider: %w", err)
|
||||
return nil, wrapProviderErr("Google", "provider", err)
|
||||
}
|
||||
|
||||
model, err := provider.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Google model: %w", err)
|
||||
return nil, wrapProviderErr("Google", "model", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
@@ -929,12 +1401,12 @@ func createAzureProvider(ctx context.Context, config *ProviderConfig, modelName
|
||||
|
||||
provider, err := azure.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Azure OpenAI provider: %w", err)
|
||||
return nil, wrapProviderErr("Azure OpenAI", "provider", err)
|
||||
}
|
||||
|
||||
model, err := provider.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Azure OpenAI model: %w", err)
|
||||
return nil, wrapProviderErr("Azure OpenAI", "model", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
@@ -954,12 +1426,12 @@ func createOpenRouterProvider(ctx context.Context, config *ProviderConfig, model
|
||||
|
||||
provider, err := openrouter.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OpenRouter provider: %w", err)
|
||||
return nil, wrapProviderErr("OpenRouter", "provider", err)
|
||||
}
|
||||
|
||||
model, err := provider.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OpenRouter model: %w", err)
|
||||
return nil, wrapProviderErr("OpenRouter", "model", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
@@ -971,12 +1443,12 @@ func createBedrockProvider(ctx context.Context, config *ProviderConfig, modelNam
|
||||
// Bedrock uses AWS SDK default credential chain (env vars, shared config, etc.)
|
||||
provider, err := bedrock.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Bedrock provider: %w", err)
|
||||
return nil, wrapProviderErr("Bedrock", "provider", err)
|
||||
}
|
||||
|
||||
model, err := provider.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Bedrock model: %w", err)
|
||||
return nil, wrapProviderErr("Bedrock", "model", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
@@ -1000,12 +1472,12 @@ func createVercelProvider(ctx context.Context, config *ProviderConfig, modelName
|
||||
|
||||
provider, err := vercel.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Vercel provider: %w", err)
|
||||
return nil, wrapProviderErr("Vercel", "provider", err)
|
||||
}
|
||||
|
||||
model, err := provider.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Vercel model: %w", err)
|
||||
return nil, wrapProviderErr("Vercel", "model", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
@@ -1058,12 +1530,12 @@ func createCustomProvider(ctx context.Context, config *ProviderConfig, modelName
|
||||
|
||||
p, err := openai.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create custom provider: %w", err)
|
||||
return nil, wrapProviderErr("custom", "provider", err)
|
||||
}
|
||||
|
||||
model, err := p.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create custom model: %w", err)
|
||||
return nil, wrapProviderErr("custom", "model", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
@@ -1107,12 +1579,12 @@ func createOllamaProvider(ctx context.Context, config *ProviderConfig, modelName
|
||||
|
||||
provider, err := openaicompat.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Ollama provider: %w", err)
|
||||
return nil, wrapProviderErr("Ollama", "provider", err)
|
||||
}
|
||||
|
||||
model, err := provider.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Ollama model: %w", err)
|
||||
return nil, wrapProviderErr("Ollama", "model", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
@@ -111,13 +112,30 @@ func NewModelsRegistry() *ModelsRegistry {
|
||||
}
|
||||
|
||||
// buildFromModelsDB converts models.dev provider data into our internal format.
|
||||
// It tries the on-disk cache first and falls back to the embedded database.
|
||||
// It starts from the compile-time embedded database and merges on-disk cached
|
||||
// data from `kit update-models` on top. Cached provider metadata replaces
|
||||
// embedded metadata, and model entries are merged with cached models taking
|
||||
// precedence. This means newly synced models are available while embedded
|
||||
// models that haven't been synced yet are still reachable.
|
||||
func buildFromModelsDB() map[string]ProviderInfo {
|
||||
// Try cached data first (from `kit update-models`)
|
||||
dbProviders, _ := LoadCachedProviders()
|
||||
if len(dbProviders) == 0 {
|
||||
// Fall back to compile-time embedded data
|
||||
dbProviders = loadEmbeddedProviders()
|
||||
// Start with compile-time embedded data as the base.
|
||||
dbProviders := loadEmbeddedProviders()
|
||||
if dbProviders == nil {
|
||||
dbProviders = make(ModelsDBProviders)
|
||||
}
|
||||
|
||||
// Merge on-disk cached data on top (cached takes precedence).
|
||||
if cached, _ := LoadCachedProviders(); len(cached) > 0 {
|
||||
for providerID, cp := range cached {
|
||||
if existing, ok := dbProviders[providerID]; ok {
|
||||
// Merge models: embedded base + cached overrides.
|
||||
mergedModels := make(map[string]modelsDBModel, len(existing.Models)+len(cp.Models))
|
||||
maps.Copy(mergedModels, existing.Models)
|
||||
maps.Copy(mergedModels, cp.Models)
|
||||
cp.Models = mergedModels
|
||||
}
|
||||
dbProviders[providerID] = cp
|
||||
}
|
||||
}
|
||||
|
||||
providers := make(map[string]ProviderInfo, len(dbProviders))
|
||||
@@ -228,6 +246,7 @@ func loadEmbeddedProviders() map[string]modelsDBProvider {
|
||||
// doesn't track yet. Callers should treat a nil return as "unknown model"
|
||||
// and continue with sensible defaults.
|
||||
func (r *ModelsRegistry) LookupModel(provider, modelID string) *ModelInfo {
|
||||
provider = catalogProviderID(provider)
|
||||
providerInfo, exists := r.providers[provider]
|
||||
if !exists {
|
||||
return nil
|
||||
@@ -255,6 +274,7 @@ func LookupModelForSettings(modelString string) *ModelInfo {
|
||||
|
||||
// getRequiredEnvVars returns the required environment variables for a provider.
|
||||
func (r *ModelsRegistry) getRequiredEnvVars(provider string) ([]string, error) {
|
||||
provider = catalogProviderID(provider)
|
||||
providerInfo, exists := r.providers[provider]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("unsupported provider: %s", provider)
|
||||
@@ -269,6 +289,7 @@ func (r *ModelsRegistry) getRequiredEnvVars(provider string) ([]string, error) {
|
||||
// variables. Returns nil for providers not in the registry (unknown
|
||||
// providers are assumed to handle auth themselves or via --provider-api-key).
|
||||
func (r *ModelsRegistry) ValidateEnvironment(provider string, apiKey string) error {
|
||||
provider = catalogProviderID(provider)
|
||||
if apiKey != "" {
|
||||
return nil
|
||||
}
|
||||
@@ -293,6 +314,15 @@ func (r *ModelsRegistry) ValidateEnvironment(provider string, apiKey string) err
|
||||
}
|
||||
}
|
||||
|
||||
// For GitHub Copilot, check stored GitHub OAuth credentials.
|
||||
if provider == copilotProviderID {
|
||||
if cm, err := auth.NewCredentialManager(); err == nil {
|
||||
if has, _ := cm.HasCopilotCredentials(); has {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
envVars, err := r.getRequiredEnvVars(provider)
|
||||
if err != nil {
|
||||
// Unknown provider — nothing to validate
|
||||
@@ -332,6 +362,7 @@ func (r *ModelsRegistry) ValidateEnvironment(provider string, apiKey string) err
|
||||
|
||||
// SuggestModels returns similar model names when an invalid model is provided.
|
||||
func (r *ModelsRegistry) SuggestModels(provider, invalidModel string) []string {
|
||||
provider = catalogProviderID(provider)
|
||||
providerInfo, exists := r.providers[provider]
|
||||
if !exists {
|
||||
return nil
|
||||
@@ -386,8 +417,8 @@ func isProviderLLMSupported(providerID string, info *ProviderInfo) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if npm maps to an LLM provider
|
||||
if _, ok := npmToLLMProvider[info.NPM]; ok {
|
||||
// Check if npm maps to a known wire protocol
|
||||
if _, ok := npmToWireProtocol[info.NPM]; ok {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -397,6 +428,7 @@ func isProviderLLMSupported(providerID string, info *ProviderInfo) bool {
|
||||
|
||||
// GetModelsForProvider returns all models for a specific provider.
|
||||
func (r *ModelsRegistry) GetModelsForProvider(provider string) (map[string]ModelInfo, error) {
|
||||
provider = catalogProviderID(provider)
|
||||
providerInfo, exists := r.providers[provider]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("unsupported provider: %s", provider)
|
||||
@@ -407,6 +439,7 @@ func (r *ModelsRegistry) GetModelsForProvider(provider string) (map[string]Model
|
||||
|
||||
// GetProviderInfo returns the full provider info, or nil if not found.
|
||||
func (r *ModelsRegistry) GetProviderInfo(provider string) *ProviderInfo {
|
||||
provider = catalogProviderID(provider)
|
||||
info, exists := r.providers[provider]
|
||||
if !exists {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// bindMaxTokensFlag wires a fresh pflag-backed "max-tokens" key into viper so
|
||||
// isExplicitlySet behaves the same way it does in production. Returns a
|
||||
// cleanup function that removes the binding so sibling tests see a clean
|
||||
// state.
|
||||
func bindMaxTokensFlag(t *testing.T, args []string) func() {
|
||||
t.Helper()
|
||||
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
|
||||
fs.Int("max-tokens", 8192, "")
|
||||
if err := viper.BindPFlag("max-tokens", fs.Lookup("max-tokens")); err != nil {
|
||||
t.Fatalf("BindPFlag: %v", err)
|
||||
}
|
||||
if err := fs.Parse(args); err != nil {
|
||||
t.Fatalf("fs.Parse: %v", err)
|
||||
}
|
||||
return func() {
|
||||
viper.Reset()
|
||||
}
|
||||
}
|
||||
|
||||
func TestRightSizeMaxTokens_RaisesWhenBelowCeiling(t *testing.T) {
|
||||
cleanup := bindMaxTokensFlag(t, nil) // no args → flag.Changed = false
|
||||
defer cleanup()
|
||||
|
||||
config := &ProviderConfig{MaxTokens: 8192}
|
||||
modelInfo := &ModelInfo{
|
||||
ID: "claude-sonnet-4-5",
|
||||
Limit: Limit{Context: 200000, Output: 64000},
|
||||
}
|
||||
|
||||
rightSizeMaxTokens(config, modelInfo)
|
||||
|
||||
if config.MaxTokens != 32768 {
|
||||
t.Errorf("expected MaxTokens raised to defaultRightSizeCap (32768), got %d", config.MaxTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRightSizeMaxTokens_CapsAtDefaultRightSizeCap(t *testing.T) {
|
||||
cleanup := bindMaxTokensFlag(t, nil)
|
||||
defer cleanup()
|
||||
|
||||
config := &ProviderConfig{MaxTokens: 8192}
|
||||
// Mistral Devstral has 262144 output — we should still cap at 32768.
|
||||
modelInfo := &ModelInfo{
|
||||
ID: "devstral-medium-latest",
|
||||
Limit: Limit{Context: 262144, Output: 262144},
|
||||
}
|
||||
|
||||
rightSizeMaxTokens(config, modelInfo)
|
||||
|
||||
if config.MaxTokens != defaultRightSizeCap {
|
||||
t.Errorf("expected MaxTokens capped at %d, got %d", defaultRightSizeCap, config.MaxTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRightSizeMaxTokens_UsesExactOutputWhenBelowCap(t *testing.T) {
|
||||
cleanup := bindMaxTokensFlag(t, nil)
|
||||
defer cleanup()
|
||||
|
||||
config := &ProviderConfig{MaxTokens: 4096}
|
||||
// Model with output limit smaller than the cap.
|
||||
modelInfo := &ModelInfo{
|
||||
ID: "gpt-4",
|
||||
Limit: Limit{Context: 8192, Output: 8192},
|
||||
}
|
||||
|
||||
rightSizeMaxTokens(config, modelInfo)
|
||||
|
||||
if config.MaxTokens != 8192 {
|
||||
t.Errorf("expected MaxTokens raised to model output ceiling (8192), got %d", config.MaxTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRightSizeMaxTokens_DoesNotLowerCurrentValue(t *testing.T) {
|
||||
cleanup := bindMaxTokensFlag(t, nil)
|
||||
defer cleanup()
|
||||
|
||||
// User (via per-model settings, applied earlier) already bumped MaxTokens
|
||||
// above the cap — we must not clobber their choice.
|
||||
config := &ProviderConfig{MaxTokens: 100000}
|
||||
modelInfo := &ModelInfo{
|
||||
ID: "devstral-medium-latest",
|
||||
Limit: Limit{Context: 262144, Output: 262144},
|
||||
}
|
||||
|
||||
rightSizeMaxTokens(config, modelInfo)
|
||||
|
||||
if config.MaxTokens != 100000 {
|
||||
t.Errorf("expected MaxTokens preserved at 100000, got %d", config.MaxTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRightSizeMaxTokens_RespectsExplicitFlag(t *testing.T) {
|
||||
// Simulate `--max-tokens 4096` on the command line.
|
||||
cleanup := bindMaxTokensFlag(t, []string{"--max-tokens", "4096"})
|
||||
defer cleanup()
|
||||
|
||||
config := &ProviderConfig{MaxTokens: 4096}
|
||||
modelInfo := &ModelInfo{
|
||||
ID: "claude-sonnet-4-5",
|
||||
Limit: Limit{Context: 200000, Output: 64000},
|
||||
}
|
||||
|
||||
rightSizeMaxTokens(config, modelInfo)
|
||||
|
||||
if config.MaxTokens != 4096 {
|
||||
t.Errorf("expected explicit --max-tokens to be preserved (4096), got %d", config.MaxTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRightSizeMaxTokens_NilModelInfo(t *testing.T) {
|
||||
cleanup := bindMaxTokensFlag(t, nil)
|
||||
defer cleanup()
|
||||
|
||||
config := &ProviderConfig{MaxTokens: 8192}
|
||||
// Custom model / Ollama / unknown provider → no model info.
|
||||
rightSizeMaxTokens(config, nil)
|
||||
|
||||
if config.MaxTokens != 8192 {
|
||||
t.Errorf("expected MaxTokens unchanged with nil modelInfo, got %d", config.MaxTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRightSizeMaxTokens_ZeroOutputLimit(t *testing.T) {
|
||||
cleanup := bindMaxTokensFlag(t, nil)
|
||||
defer cleanup()
|
||||
|
||||
config := &ProviderConfig{MaxTokens: 8192}
|
||||
// Model present in catalog but with no known output limit.
|
||||
modelInfo := &ModelInfo{
|
||||
ID: "unknown-model",
|
||||
Limit: Limit{Context: 0, Output: 0},
|
||||
}
|
||||
|
||||
rightSizeMaxTokens(config, modelInfo)
|
||||
|
||||
if config.MaxTokens != 8192 {
|
||||
t.Errorf("expected MaxTokens unchanged with zero output limit, got %d", config.MaxTokens)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"charm.land/fantasy/providers/google"
|
||||
)
|
||||
|
||||
// templatePlaceholderRe matches "${NAME}" placeholders in URL templates from
|
||||
// models.dev (e.g. "https://${DATABRICKS_HOST}/ai-gateway/mlflow/v1").
|
||||
var templatePlaceholderRe = regexp.MustCompile(`\$\{([A-Z0-9_]+)\}`)
|
||||
|
||||
// templateEnvVarOverrides supplies fallback environment variable names for
|
||||
// placeholders that providers commonly use under non-obvious env names.
|
||||
// The placeholder name itself is always tried first; this map adds extra
|
||||
// names to try when the placeholder doesn't match the canonical env var.
|
||||
var templateEnvVarOverrides = map[string][]string{
|
||||
"CLOUDFLARE_ACCOUNT_ID": {"CF_ACCOUNT_ID"},
|
||||
"CLOUDFLARE_GATEWAY_NAME": {"CF_GATEWAY", "CLOUDFLARE_GATEWAY"},
|
||||
"DATABRICKS_HOST": {"DATABRICKS_WORKSPACE_URL"},
|
||||
"SNOWFLAKE_ACCOUNT": {"SNOWFLAKE_ACCOUNT_ID"},
|
||||
}
|
||||
|
||||
// resolveTemplatedAPIURL substitutes "${VAR}" placeholders in apiURL with the
|
||||
// values of the named environment variables. Returns:
|
||||
// - ("", nil) when apiURL contains no placeholders (caller keeps current URL),
|
||||
// - (resolved, nil) when every placeholder was resolved,
|
||||
// - ("", error) when one or more placeholders are unset, with a message that
|
||||
// names the missing env vars and points at the relevant provider.
|
||||
//
|
||||
// The info parameter is used purely for error messaging (provider name).
|
||||
func resolveTemplatedAPIURL(apiURL string, info *ProviderInfo) (string, error) {
|
||||
if apiURL == "" || !strings.Contains(apiURL, "${") {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var missing []string
|
||||
resolved := templatePlaceholderRe.ReplaceAllStringFunc(apiURL, func(match string) string {
|
||||
// match is "${NAME}". Extract NAME.
|
||||
name := match[2 : len(match)-1]
|
||||
if v := os.Getenv(name); v != "" {
|
||||
return v
|
||||
}
|
||||
for _, alt := range templateEnvVarOverrides[name] {
|
||||
if v := os.Getenv(alt); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
missing = append(missing, name)
|
||||
return match
|
||||
})
|
||||
|
||||
if len(missing) > 0 {
|
||||
providerName := info.ID
|
||||
if info.Name != "" {
|
||||
providerName = info.Name
|
||||
}
|
||||
return "", fmt.Errorf(
|
||||
"provider %s requires environment variable(s) %s to construct its API URL (%s); "+
|
||||
"set them or pass --provider-url to override",
|
||||
providerName, strings.Join(missing, ", "), apiURL,
|
||||
)
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// ResolveProviderBaseURL returns the base API URL kit will use when talking to
|
||||
// the given provider, applying the same resolution order as CreateProvider:
|
||||
//
|
||||
// 1. The provider's `api` field from the models.dev registry.
|
||||
// 2. The hard-coded default base URL of its npm SDK package (e.g.
|
||||
// @ai-sdk/groq → https://api.groq.com/openai/v1).
|
||||
// 3. Template substitution against the current process environment when the
|
||||
// URL contains "${VAR}" placeholders (e.g. cloudflare-workers-ai needs
|
||||
// CLOUDFLARE_ACCOUNT_ID).
|
||||
//
|
||||
// It returns an error when the provider is unknown, when no URL can be derived,
|
||||
// or when a templated URL has unset placeholders. The error message is suitable
|
||||
// for direct display to end users.
|
||||
//
|
||||
// Note: providers handled by bespoke auth schemes (amazon-bedrock SigV4,
|
||||
// azure resource URLs, google-vertex project/location, sap-ai-core customer
|
||||
// deployments) may return either an empty URL or a regional/templated URL —
|
||||
// the actual endpoint is finalised inside their native handlers and depends on
|
||||
// runtime credentials.
|
||||
func ResolveProviderBaseURL(providerID string) (string, error) {
|
||||
registry := GetGlobalRegistry()
|
||||
info := registry.GetProviderInfo(providerID)
|
||||
if info == nil {
|
||||
return "", fmt.Errorf("unknown provider: %s", providerID)
|
||||
}
|
||||
|
||||
apiURL := info.API
|
||||
if apiURL == "" {
|
||||
if defaultURL, ok := sdkDefaultBaseURL[info.NPM]; ok {
|
||||
apiURL = defaultURL
|
||||
}
|
||||
}
|
||||
|
||||
if apiURL == "" {
|
||||
return "", fmt.Errorf(
|
||||
"provider %s has no default API URL: its npm package %q does not "+
|
||||
"ship a built-in baseURL (likely Bedrock SigV4, Azure deployment, "+
|
||||
"Vertex project/location, or a customer-hosted endpoint). "+
|
||||
"Pass --provider-url or set the provider's URL env var",
|
||||
providerID, info.NPM,
|
||||
)
|
||||
}
|
||||
|
||||
if strings.Contains(apiURL, "${") {
|
||||
resolved, err := resolveTemplatedAPIURL(apiURL, info)
|
||||
if err != nil {
|
||||
return apiURL, err
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
return apiURL, nil
|
||||
}
|
||||
|
||||
// createGoogleVertexProvider creates a Google Gemini provider that targets the
|
||||
// Vertex AI backend (rather than the public generativelanguage.googleapis.com
|
||||
// endpoint). It requires the same project/region environment variables as
|
||||
// google-vertex-anthropic.
|
||||
func createGoogleVertexProvider(ctx context.Context, config *ProviderConfig, modelName string) (*ProviderResult, error) {
|
||||
projectID := firstNonEmpty(
|
||||
os.Getenv("GOOGLE_VERTEX_PROJECT"),
|
||||
os.Getenv("GOOGLE_CLOUD_PROJECT"),
|
||||
os.Getenv("GCLOUD_PROJECT"),
|
||||
os.Getenv("CLOUDSDK_CORE_PROJECT"),
|
||||
)
|
||||
if projectID == "" {
|
||||
return nil, fmt.Errorf(
|
||||
"google Vertex project ID not provided, set GOOGLE_VERTEX_PROJECT, " +
|
||||
"GOOGLE_CLOUD_PROJECT, or GCLOUD_PROJECT environment variable",
|
||||
)
|
||||
}
|
||||
|
||||
region := firstNonEmpty(
|
||||
os.Getenv("GOOGLE_VERTEX_LOCATION"),
|
||||
os.Getenv("CLOUD_ML_REGION"),
|
||||
)
|
||||
if region == "" {
|
||||
region = "global"
|
||||
}
|
||||
|
||||
opts := []google.Option{
|
||||
google.WithVertex(projectID, region),
|
||||
google.WithName("google-vertex"),
|
||||
}
|
||||
|
||||
if config.TLSSkipVerify {
|
||||
opts = append(opts, google.WithHTTPClient(createHTTPClientWithTLSConfig(true)))
|
||||
}
|
||||
|
||||
provider, err := google.New(opts...)
|
||||
if err != nil {
|
||||
return nil, wrapProviderErr("Google Vertex", "provider", err)
|
||||
}
|
||||
|
||||
model, err := provider.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, wrapProviderErr("Google Vertex", "model", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSDKDefaultBaseURL_CoversAllWireMappedPackages enforces the invariant
|
||||
// that every npm package recognised by the auto-router has a corresponding
|
||||
// default base URL — otherwise a provider that omits its `api` field in the
|
||||
// registry would silently fail to route at runtime.
|
||||
func TestSDKDefaultBaseURL_CoversAllWireMappedPackages(t *testing.T) {
|
||||
for npm := range npmToWireProtocol {
|
||||
// @ai-sdk/openai-compatible is a wire family, not a single SDK with
|
||||
// a default URL — providers using it always supply their own `api`.
|
||||
if npm == "@ai-sdk/openai-compatible" {
|
||||
continue
|
||||
}
|
||||
if _, ok := sdkDefaultBaseURL[npm]; !ok {
|
||||
t.Errorf("npm %q is in npmToWireProtocol but has no sdkDefaultBaseURL entry — "+
|
||||
"providers using this npm with no `api` field cannot be routed", npm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSDKDefaultBaseURL_AllURLsAreAbsolute sanity-checks that every default
|
||||
// URL is a well-formed absolute https endpoint (catches typos in the table).
|
||||
func TestSDKDefaultBaseURL_AllURLsAreAbsolute(t *testing.T) {
|
||||
for npm, url := range sdkDefaultBaseURL {
|
||||
if !strings.HasPrefix(url, "https://") {
|
||||
t.Errorf("sdkDefaultBaseURL[%q] = %q is not an absolute https URL", npm, url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveProviderBaseURL_RegistryFirst verifies that the registry's `api`
|
||||
// field wins over any SDK default.
|
||||
func TestResolveProviderBaseURL_RegistryFirst(t *testing.T) {
|
||||
// xai is in the registry with no `api` field — its URL comes from the
|
||||
// SDK default. Use a synthetic registry-backed provider to test the
|
||||
// priority via the public registry instead.
|
||||
url, err := ResolveProviderBaseURL("openai")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveProviderBaseURL(openai): %v", err)
|
||||
}
|
||||
if url != "https://api.openai.com/v1" {
|
||||
t.Errorf("openai URL = %q, want https://api.openai.com/v1", url)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveProviderBaseURL_SDKDefaultFallback verifies that providers
|
||||
// without an `api` field (groq, cerebras, xai, …) resolve to their SDK
|
||||
// hard-coded default URL.
|
||||
func TestResolveProviderBaseURL_SDKDefaultFallback(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"groq": "https://api.groq.com/openai/v1",
|
||||
"cerebras": "https://api.cerebras.ai/v1",
|
||||
"xai": "https://api.x.ai/v1",
|
||||
"mistral": "https://api.mistral.ai/v1",
|
||||
"perplexity": "https://api.perplexity.ai",
|
||||
"togetherai": "https://api.together.xyz/v1",
|
||||
"deepinfra": "https://api.deepinfra.com/v1/openai",
|
||||
"cohere": "https://api.cohere.com/compatibility/v1",
|
||||
"v0": "https://api.v0.dev/v1",
|
||||
"aihubmix": "https://aihubmix.com/v1",
|
||||
"venice": "https://api.venice.ai/api/v1",
|
||||
"openrouter": "https://openrouter.ai/api/v1",
|
||||
}
|
||||
for providerID, wantURL := range tests {
|
||||
t.Run(providerID, func(t *testing.T) {
|
||||
got, err := ResolveProviderBaseURL(providerID)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveProviderBaseURL(%s): %v", providerID, err)
|
||||
}
|
||||
if got != wantURL {
|
||||
t.Errorf("%s URL = %q, want %q", providerID, got, wantURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveProviderBaseURL_TemplatedURL_MissingEnv verifies that providers
|
||||
// whose URL contains "${VAR}" placeholders surface a targeted error when the
|
||||
// environment variables are unset.
|
||||
func TestResolveProviderBaseURL_TemplatedURL_MissingEnv(t *testing.T) {
|
||||
// cloudflare-workers-ai's api URL contains ${CLOUDFLARE_ACCOUNT_ID}.
|
||||
// Ensure the variable is unset for this test.
|
||||
t.Setenv("CLOUDFLARE_ACCOUNT_ID", "")
|
||||
t.Setenv("CF_ACCOUNT_ID", "")
|
||||
|
||||
_, err := ResolveProviderBaseURL("cloudflare-workers-ai")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unset CLOUDFLARE_ACCOUNT_ID, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "CLOUDFLARE_ACCOUNT_ID") {
|
||||
t.Errorf("error should name the missing env var, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--provider-url") {
|
||||
t.Errorf("error should suggest --provider-url override, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveProviderBaseURL_TemplatedURL_Resolved verifies env-var
|
||||
// substitution succeeds when the placeholder is set.
|
||||
func TestResolveProviderBaseURL_TemplatedURL_Resolved(t *testing.T) {
|
||||
t.Setenv("CLOUDFLARE_ACCOUNT_ID", "test-acct-123")
|
||||
got, err := ResolveProviderBaseURL("cloudflare-workers-ai")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveProviderBaseURL: %v", err)
|
||||
}
|
||||
if !strings.Contains(got, "test-acct-123") {
|
||||
t.Errorf("resolved URL %q should contain test-acct-123", got)
|
||||
}
|
||||
if strings.Contains(got, "${") {
|
||||
t.Errorf("resolved URL %q still contains template placeholder", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveProviderBaseURL_UnknownProvider verifies the not-in-registry error.
|
||||
func TestResolveProviderBaseURL_UnknownProvider(t *testing.T) {
|
||||
_, err := ResolveProviderBaseURL("does-not-exist")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown provider, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown provider") {
|
||||
t.Errorf("error should say 'unknown provider', got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoRouteProvider_SDKDefaultURLFallback verifies that providers whose
|
||||
// registry entry omits the `api` field (groq, mistral, xai, etc.) are still
|
||||
// auto-routed by falling back to the SDK's hard-coded default URL.
|
||||
func TestAutoRouteProvider_SDKDefaultURLFallback(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
npmPackage string
|
||||
wantInURL string
|
||||
}{
|
||||
{"groq", "@ai-sdk/groq", "groq.com"},
|
||||
{"cerebras", "@ai-sdk/cerebras", "cerebras.ai"},
|
||||
{"xai", "@ai-sdk/xai", "x.ai"},
|
||||
{"mistral", "@ai-sdk/mistral", "mistral.ai"},
|
||||
{"v0", "@ai-sdk/vercel", "v0.dev"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reg := &ModelsRegistry{
|
||||
providers: map[string]ProviderInfo{
|
||||
"testfallback": {
|
||||
ID: "testfallback",
|
||||
Name: "Test Fallback",
|
||||
Env: []string{"TESTFALLBACK_API_KEY"},
|
||||
NPM: tt.npmPackage,
|
||||
// API intentionally omitted — must fall back to SDK default.
|
||||
Models: map[string]ModelInfo{
|
||||
"any-model": {ID: "any-model", Name: "any-model"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
config := &ProviderConfig{ProviderAPIKey: "test-key"}
|
||||
|
||||
result, err := autoRouteProvider(context.Background(), config, "testfallback", "any-model", reg)
|
||||
if err != nil {
|
||||
t.Fatalf("autoRouteProvider returned error: %v", err)
|
||||
}
|
||||
if result == nil || result.Model == nil {
|
||||
t.Fatal("autoRouteProvider returned nil model")
|
||||
}
|
||||
// Verify the SDK default URL was picked up.
|
||||
if !strings.Contains(config.ProviderURL, tt.wantInURL) {
|
||||
t.Errorf("config.ProviderURL = %q, want substring %q (SDK default)",
|
||||
config.ProviderURL, tt.wantInURL)
|
||||
}
|
||||
// All these wrappers route through the openai-compat wire.
|
||||
gotType := reflect.TypeOf(result.Model).String()
|
||||
if gotType != "openai.languageModel" {
|
||||
t.Errorf("model type = %q, want openai.languageModel", gotType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveTemplatedAPIURL_NoPlaceholders verifies that URLs without
|
||||
// placeholders are returned as-is (the caller keeps using the original).
|
||||
func TestResolveTemplatedAPIURL_NoPlaceholders(t *testing.T) {
|
||||
got, err := resolveTemplatedAPIURL("https://api.example.com/v1", &ProviderInfo{ID: "x"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Errorf("got %q, want empty string for URL with no placeholders", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveTemplatedAPIURL_AltEnvVar verifies that the alternative env-var
|
||||
// names (e.g. CF_ACCOUNT_ID for CLOUDFLARE_ACCOUNT_ID) are honoured.
|
||||
func TestResolveTemplatedAPIURL_AltEnvVar(t *testing.T) {
|
||||
t.Setenv("CLOUDFLARE_ACCOUNT_ID", "")
|
||||
t.Setenv("CF_ACCOUNT_ID", "alt-name-123")
|
||||
|
||||
got, err := resolveTemplatedAPIURL(
|
||||
"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
|
||||
&ProviderInfo{ID: "cloudflare-workers-ai"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(got, "alt-name-123") {
|
||||
t.Errorf("resolved URL %q should have picked up CF_ACCOUNT_ID alternative", got)
|
||||
}
|
||||
}
|
||||
+39
-35
@@ -36,15 +36,17 @@ type Diagnostic struct {
|
||||
}
|
||||
|
||||
// LoadAll discovers and loads all prompt templates from standard locations
|
||||
// and any extra paths. Templates are loaded in order of precedence (lowest
|
||||
// to highest), with later templates overriding earlier ones of the same name.
|
||||
// and any extra paths. Templates are loaded in order of precedence (highest
|
||||
// to lowest); the first source to define a given name wins, later definitions
|
||||
// of the same name are dropped with a diagnostic.
|
||||
//
|
||||
// Discovery paths searched in order:
|
||||
// 1. Default templates (if IncludeDefaults)
|
||||
// 2. ~/.kit/prompts/ (global user templates)
|
||||
// 3. .kit/prompts/ (project-local templates)
|
||||
// 4. ConfigPaths (from configuration)
|
||||
// 5. ExtraPaths (explicit paths, highest precedence)
|
||||
// 2. ~/.kit/prompts/ (legacy global)
|
||||
// 3. $XDG_CONFIG_HOME/kit/prompts/ (XDG global, default ~/.config/kit/prompts/)
|
||||
// 4. <cwd>/.kit/prompts/ (project-local templates)
|
||||
// 5. ConfigPaths (from configuration)
|
||||
// 6. ExtraPaths (explicit paths, lowest precedence)
|
||||
func LoadAll(opts LoadOptions) ([]*PromptTemplate, []Diagnostic, error) {
|
||||
if opts.Cwd == "" {
|
||||
opts.Cwd, _ = os.Getwd()
|
||||
@@ -88,13 +90,21 @@ func LoadAll(opts LoadOptions) ([]*PromptTemplate, []Diagnostic, error) {
|
||||
addTemplates(defaults, "default")
|
||||
}
|
||||
|
||||
// 2. Global user templates: ~/.kit/prompts/
|
||||
globalDir := filepath.Join(opts.HomeDir, ".kit", "prompts")
|
||||
if templates, err := LoadFromDir(globalDir); err == nil {
|
||||
// 2. Legacy global user templates: ~/.kit/prompts/
|
||||
legacyGlobalDir := filepath.Join(opts.HomeDir, ".kit", "prompts")
|
||||
if templates, err := LoadFromDir(legacyGlobalDir); err == nil {
|
||||
addTemplates(templates, "global")
|
||||
}
|
||||
|
||||
// 3. Project-local templates: .kit/prompts/
|
||||
// 3. XDG global user templates: $XDG_CONFIG_HOME/kit/prompts/
|
||||
// Default: ~/.config/kit/prompts/. Aligns with extensions and skills.
|
||||
if xdgDir := GlobalDir(); xdgDir != "" && xdgDir != legacyGlobalDir {
|
||||
if templates, err := LoadFromDir(xdgDir); err == nil {
|
||||
addTemplates(templates, "global")
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Project-local templates: .kit/prompts/
|
||||
localDir := filepath.Join(opts.Cwd, ".kit", "prompts")
|
||||
if templates, err := LoadFromDir(localDir); err == nil {
|
||||
addTemplates(templates, "local")
|
||||
@@ -179,31 +189,6 @@ func LoadFromDir(dir string) ([]*PromptTemplate, error) {
|
||||
return templates, nil
|
||||
}
|
||||
|
||||
// Deduplicate removes duplicate templates by name, keeping the first occurrence.
|
||||
// It returns the deduplicated list and diagnostics for any collisions.
|
||||
// This is a standalone function for when you need to deduplicate an existing list.
|
||||
func Deduplicate(templates []*PromptTemplate) ([]*PromptTemplate, []Diagnostic) {
|
||||
seen := make(map[string]*PromptTemplate)
|
||||
var result []*PromptTemplate
|
||||
var diagnostics []Diagnostic
|
||||
|
||||
for _, tpl := range templates {
|
||||
if existing, ok := seen[tpl.Name]; ok {
|
||||
diagnostics = append(diagnostics, Diagnostic{
|
||||
Name: tpl.Name,
|
||||
KeptPath: existing.FilePath,
|
||||
DroppedPath: tpl.FilePath,
|
||||
Reason: "duplicate template name (first-match-wins)",
|
||||
})
|
||||
} else {
|
||||
seen[tpl.Name] = tpl
|
||||
result = append(result, tpl)
|
||||
}
|
||||
}
|
||||
|
||||
return result, diagnostics
|
||||
}
|
||||
|
||||
// loadDefaultTemplates returns the built-in default templates.
|
||||
// These are embedded templates that ship with Kit.
|
||||
func loadDefaultTemplates() []*PromptTemplate {
|
||||
@@ -211,3 +196,22 @@ func loadDefaultTemplates() []*PromptTemplate {
|
||||
// For now, return an empty slice - users can define their own templates
|
||||
return nil
|
||||
}
|
||||
|
||||
// GlobalDir returns the XDG-aligned global prompts directory, respecting
|
||||
// $XDG_CONFIG_HOME. Defaults to ~/.config/kit/prompts/. Returns an empty
|
||||
// string if the user's home directory cannot be resolved.
|
||||
//
|
||||
// This is the canonical location for user-wide prompt templates and aligns
|
||||
// with the discovery paths used for extensions ($XDG_CONFIG_HOME/kit/extensions/)
|
||||
// and skills ($XDG_CONFIG_HOME/kit/skills/).
|
||||
func GlobalDir() string {
|
||||
base := os.Getenv("XDG_CONFIG_HOME")
|
||||
if base == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
base = filepath.Join(home, ".config")
|
||||
}
|
||||
return filepath.Join(base, "kit", "prompts")
|
||||
}
|
||||
|
||||
@@ -70,7 +70,8 @@ func ParseTemplate(path string) (*PromptTemplate, error) {
|
||||
}
|
||||
|
||||
// ParseCommandArgs splits a command line into arguments respecting quotes.
|
||||
// It handles single quotes, double quotes, and backslash escaping.
|
||||
// It handles single quotes, double quotes, backslash escaping, and splits on
|
||||
// spaces and tabs.
|
||||
func ParseCommandArgs(input string) []string {
|
||||
var args []string
|
||||
var current strings.Builder
|
||||
@@ -78,7 +79,7 @@ func ParseCommandArgs(input string) []string {
|
||||
inDoubleQuote := false
|
||||
escaped := false
|
||||
|
||||
for i, r := range input {
|
||||
for _, r := range input {
|
||||
if escaped {
|
||||
current.WriteRune(r)
|
||||
escaped = false
|
||||
@@ -101,7 +102,7 @@ func ParseCommandArgs(input string) []string {
|
||||
continue
|
||||
}
|
||||
|
||||
if r == ' ' && !inSingleQuote && !inDoubleQuote {
|
||||
if (r == ' ' || r == '\t') && !inSingleQuote && !inDoubleQuote {
|
||||
if current.Len() > 0 {
|
||||
args = append(args, current.String())
|
||||
current.Reset()
|
||||
@@ -110,7 +111,6 @@ func ParseCommandArgs(input string) []string {
|
||||
}
|
||||
|
||||
current.WriteRune(r)
|
||||
_ = i // silence unused warning when we need position later
|
||||
}
|
||||
|
||||
if current.Len() > 0 {
|
||||
@@ -325,8 +325,3 @@ func (t *PromptTemplate) Expand(argsInput string) string {
|
||||
args := ParseCommandArgs(argsInput)
|
||||
return SubstituteArgs(t.Content, args)
|
||||
}
|
||||
|
||||
// ExpandWithArgs substitutes the provided arguments into the template content.
|
||||
func (t *PromptTemplate) ExpandWithArgs(args []string) string {
|
||||
return SubstituteArgs(t.Content, args)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mark3labs/kit/internal/message"
|
||||
)
|
||||
|
||||
// TestCompactionParentCycleRegression tests that after multiple compactions,
|
||||
// newly appended messages always have a valid parent chain and BuildContext
|
||||
// returns the correct messages.
|
||||
func TestCompactionParentCycleRegression(t *testing.T) {
|
||||
tm := InMemoryTreeSession("/test")
|
||||
|
||||
// Simulate a long conversation with multiple compactions.
|
||||
msg1, _ := tm.AppendMessage(message.Message{Role: message.RoleUser, Parts: []message.ContentPart{message.TextContent{Text: "msg1"}}})
|
||||
msg2, _ := tm.AppendMessage(message.Message{Role: message.RoleAssistant, Parts: []message.ContentPart{message.TextContent{Text: "msg2"}}})
|
||||
|
||||
// First compaction
|
||||
comp1, _ := tm.AppendCompaction("Summary 1", msg1, 1000, 500, 1, []string{}, []string{})
|
||||
|
||||
msg3, _ := tm.AppendMessage(message.Message{Role: message.RoleUser, Parts: []message.ContentPart{message.TextContent{Text: "msg3"}}})
|
||||
msg4, _ := tm.AppendMessage(message.Message{Role: message.RoleAssistant, Parts: []message.ContentPart{message.TextContent{Text: "msg4"}}})
|
||||
|
||||
// Second compaction
|
||||
comp2, _ := tm.AppendCompaction("Summary 2", msg3, 1000, 500, 1, []string{}, []string{})
|
||||
|
||||
msg5, _ := tm.AppendMessage(message.Message{Role: message.RoleUser, Parts: []message.ContentPart{message.TextContent{Text: "msg5"}}})
|
||||
msg6, _ := tm.AppendMessage(message.Message{Role: message.RoleAssistant, Parts: []message.ContentPart{message.TextContent{Text: "msg6"}}})
|
||||
|
||||
// Verify parent chain integrity
|
||||
for _, id := range []string{msg1, msg2, comp1, msg3, msg4, comp2, msg5, msg6} {
|
||||
entry := tm.GetEntry(id)
|
||||
if entry == nil {
|
||||
t.Fatalf("entry %s not found in index", id)
|
||||
}
|
||||
}
|
||||
|
||||
// Walk parent chain from msg6 — must reach root without cycles
|
||||
visited := make(map[string]bool)
|
||||
current := msg6
|
||||
for current != "" {
|
||||
if visited[current] {
|
||||
t.Fatalf("cycle detected at entry %s", current)
|
||||
}
|
||||
visited[current] = true
|
||||
entry := tm.GetEntry(current)
|
||||
if entry == nil {
|
||||
t.Fatalf("entry %s missing from index during parent walk", current)
|
||||
}
|
||||
parent := ""
|
||||
switch e := entry.(type) {
|
||||
case *MessageEntry:
|
||||
parent = e.ParentID
|
||||
case *CompactionEntry:
|
||||
parent = e.ParentID
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
|
||||
// BuildContext should return: Summary2 + msg6 + msg5 + msg3 + msg4 = 5 messages
|
||||
msgs, _, _ := tm.BuildContext()
|
||||
if len(msgs) != 5 {
|
||||
t.Fatalf("expected 5 messages, got %d: %+v", len(msgs), msgs)
|
||||
}
|
||||
}
|
||||
@@ -129,26 +129,35 @@ func TestCompactionWithNewMessagesAfterCompaction(t *testing.T) {
|
||||
msg4 := message.Message{Role: message.RoleAssistant, Parts: []message.ContentPart{message.TextContent{Text: "Message 4 - after compaction"}}}
|
||||
_, _ = tm.AppendMessage(msg4)
|
||||
|
||||
// BuildContext should return: [summary] + [M4 (new after compaction)] + [M3 (kept)]
|
||||
// BuildContext should return: [summary] + [M3 (kept)] + [M4 (new after compaction)]
|
||||
// Kept messages must appear BEFORE post-compaction messages so the LLM
|
||||
// sees the conversation in chronological order. Otherwise the latest
|
||||
// post-compaction user message would be followed by an older kept user
|
||||
// message, breaking user/assistant alternation and causing the model to
|
||||
// respond as if the post-compaction turn never happened.
|
||||
messages, _, _ := tm.BuildContext()
|
||||
if len(messages) != 3 {
|
||||
t.Fatalf("expected 3 messages (summary + M4 + M3), got %d: %+v", len(messages), messages)
|
||||
t.Fatalf("expected 3 messages (summary + M3 + M4), got %d: %+v", len(messages), messages)
|
||||
}
|
||||
|
||||
// Verify order: summary, M4 (new), M3 (kept)
|
||||
// Verify order: summary, M3 (kept), M4 (new)
|
||||
if messages[0].Role != fantasy.MessageRoleSystem {
|
||||
t.Errorf("first message should be summary, got %s", messages[0].Role)
|
||||
}
|
||||
if messages[1].Role != fantasy.MessageRoleAssistant {
|
||||
t.Errorf("second message should be assistant (M4), got %s", messages[1].Role)
|
||||
if messages[1].Role != fantasy.MessageRoleUser {
|
||||
t.Errorf("second message should be user (M3 kept), got %s", messages[1].Role)
|
||||
}
|
||||
m4Text := messages[1].Content[0].(fantasy.TextPart).Text
|
||||
m3Text := messages[1].Content[0].(fantasy.TextPart).Text
|
||||
if m3Text != "Message 3 - kept" {
|
||||
t.Errorf("unexpected M3 text: %s", m3Text)
|
||||
}
|
||||
if messages[2].Role != fantasy.MessageRoleAssistant {
|
||||
t.Errorf("third message should be assistant (M4 post-compact), got %s", messages[2].Role)
|
||||
}
|
||||
m4Text := messages[2].Content[0].(fantasy.TextPart).Text
|
||||
if m4Text != "Message 4 - after compaction" {
|
||||
t.Errorf("unexpected M4 text: %s", m4Text)
|
||||
}
|
||||
if messages[2].Role != fantasy.MessageRoleUser {
|
||||
t.Errorf("third message should be user (M3), got %s", messages[2].Role)
|
||||
}
|
||||
|
||||
// Verify that M1 is NOT in the context
|
||||
for i, msg := range messages {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestEncodeCwdForDir verifies the working-directory → session-directory
|
||||
// name encoding strips characters that are illegal on Windows (notably the
|
||||
// drive-letter colon, see issue #18) while preserving the previous output
|
||||
// for the typical Unix paths.
|
||||
func TestEncodeCwdForDir(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cwd string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "unix absolute path",
|
||||
cwd: "/home/user/proj",
|
||||
want: "home--user--proj",
|
||||
},
|
||||
{
|
||||
name: "unix relative path",
|
||||
cwd: "proj/sub",
|
||||
want: "proj--sub",
|
||||
},
|
||||
{
|
||||
name: "windows drive root",
|
||||
cwd: `C:\test`,
|
||||
want: "C--test",
|
||||
},
|
||||
{
|
||||
name: "windows nested path",
|
||||
cwd: `C:\Users\User\code`,
|
||||
want: "C--Users--User--code",
|
||||
},
|
||||
{
|
||||
name: "windows secondary drive",
|
||||
cwd: `S:\work\repo`,
|
||||
want: "S--work--repo",
|
||||
},
|
||||
{
|
||||
name: "windows mixed separators",
|
||||
cwd: `C:\Users/User\code`,
|
||||
want: "C--Users--User--code",
|
||||
},
|
||||
{
|
||||
name: "windows other illegal chars stripped",
|
||||
cwd: `C:\a<b>c|d?e*f"g`,
|
||||
want: "C--abcdefg",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := encodeCwdForDir(tc.cwd)
|
||||
if got != tc.want {
|
||||
t.Errorf("encodeCwdForDir(%q) = %q, want %q", tc.cwd, got, tc.want)
|
||||
}
|
||||
// Encoded directory must never contain characters that are
|
||||
// illegal in Windows directory names.
|
||||
for _, bad := range []string{":", "<", ">", "\"", "|", "?", "*", "\\", "/"} {
|
||||
if strings.Contains(got, bad) {
|
||||
t.Errorf("encodeCwdForDir(%q) = %q contains illegal char %q", tc.cwd, got, bad)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,10 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -97,6 +99,11 @@ func ListAllSessions() ([]SessionInfo, error) {
|
||||
|
||||
// listSessionsInDir reads all .jsonl files in a directory and extracts session info.
|
||||
// Empty sessions (no messages) are automatically cleaned up and not returned.
|
||||
//
|
||||
// Per-file extraction is parallelized across a small worker pool because each
|
||||
// file requires a full JSONL scan to compute MessageCount and FirstMessage —
|
||||
// for users with many sessions this is the dominant cost of opening the
|
||||
// session picker.
|
||||
func listSessionsInDir(dir string) ([]SessionInfo, error) {
|
||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
@@ -107,20 +114,47 @@ func listSessionsInDir(dir string) ([]SessionInfo, error) {
|
||||
return nil, fmt.Errorf("failed to read directory %s: %w", dir, err)
|
||||
}
|
||||
|
||||
var sessions []SessionInfo
|
||||
// Collect candidate paths first so we can parallelize the heavy work.
|
||||
paths := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") {
|
||||
continue
|
||||
}
|
||||
paths = append(paths, filepath.Join(dir, entry.Name()))
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
info, err := extractSessionInfo(path)
|
||||
if err != nil {
|
||||
continue // skip malformed session files
|
||||
results := make([]*SessionInfo, len(paths))
|
||||
|
||||
// Worker pool sized to GOMAXPROCS, capped to avoid thrashing for tiny lists.
|
||||
workers := max(min(runtime.GOMAXPROCS(0), len(paths)), 1)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
jobs := make(chan int, len(paths))
|
||||
for range workers {
|
||||
wg.Go(func() {
|
||||
for i := range jobs {
|
||||
info, err := extractSessionInfo(paths[i])
|
||||
if err != nil {
|
||||
continue // skip malformed session files
|
||||
}
|
||||
results[i] = info
|
||||
}
|
||||
})
|
||||
}
|
||||
for i := range paths {
|
||||
jobs <- i
|
||||
}
|
||||
close(jobs)
|
||||
wg.Wait()
|
||||
|
||||
sessions := make([]SessionInfo, 0, len(results))
|
||||
for i, info := range results {
|
||||
if info == nil {
|
||||
continue
|
||||
}
|
||||
// Clean up and skip empty sessions (no messages)
|
||||
// Clean up and skip empty sessions (no messages).
|
||||
if info.MessageCount == 0 {
|
||||
_ = os.Remove(path)
|
||||
_ = os.Remove(paths[i])
|
||||
continue
|
||||
}
|
||||
sessions = append(sessions, *info)
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mark3labs/kit/internal/message"
|
||||
)
|
||||
|
||||
// TestDetectCycleWithCorruptedParentChain tests that cycle detection works
|
||||
// when a corrupted session has circular parent references.
|
||||
func TestDetectCycleWithCorruptedParentChain(t *testing.T) {
|
||||
tm := InMemoryTreeSession("/test")
|
||||
|
||||
// Create normal chain: msg1 -> msg2 -> msg3
|
||||
id1, _ := tm.AppendMessage(message.Message{Role: message.RoleUser, Parts: []message.ContentPart{message.TextContent{Text: "msg1"}}})
|
||||
_, _ = tm.AppendMessage(message.Message{Role: message.RoleAssistant, Parts: []message.ContentPart{message.TextContent{Text: "msg2"}}})
|
||||
id3, _ := tm.AppendMessage(message.Message{Role: message.RoleUser, Parts: []message.ContentPart{message.TextContent{Text: "msg3"}}})
|
||||
|
||||
// Simulate corruption: manually set msg1's parent to msg3, creating cycle
|
||||
// This simulates the condition seen in the user's session
|
||||
for _, entry := range tm.entries {
|
||||
if e, ok := entry.(*MessageEntry); ok && e.ID == id1 {
|
||||
e.ParentID = id3 // Create cycle: msg1 -> msg3 -> ... -> msg1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// DetectCycle should find the cycle
|
||||
// The cycle is: id1 -> id3 -> id2 -> id1
|
||||
// So detecting from id3 should find id1 as the repeat
|
||||
cycle, entry := tm.DetectCycle(id3)
|
||||
if !cycle {
|
||||
t.Fatal("expected to detect cycle, but none found")
|
||||
}
|
||||
// The cycle entry could be id1 or id3 depending on where we start
|
||||
if entry != id1 && entry != id3 {
|
||||
t.Fatalf("expected cycle at %s or %s, got %s", id1, id3, entry)
|
||||
}
|
||||
|
||||
// BuildContext should still work (it has its own cycle detection)
|
||||
// but will truncate at the cycle point
|
||||
msgs, _, _ := tm.BuildContext()
|
||||
if len(msgs) == 0 {
|
||||
t.Fatal("BuildContext returned no messages")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppendMessageRejectsInvalidParent tests that AppendMessage rejects
|
||||
// appending when the current leaf has a broken parent chain.
|
||||
func TestAppendMessageRejectsInvalidParent(t *testing.T) {
|
||||
tm := InMemoryTreeSession("/test")
|
||||
|
||||
// Create normal message
|
||||
id1, err := tm.AppendMessage(message.Message{Role: message.RoleUser, Parts: []message.ContentPart{message.TextContent{Text: "msg1"}}})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to append msg1: %v", err)
|
||||
}
|
||||
|
||||
// Simulate corruption: set leafID to a non-existent ID
|
||||
tm.leafID = "non-existent-id"
|
||||
|
||||
// Next append should fail validation
|
||||
_, err = tm.AppendMessage(message.Message{Role: message.RoleAssistant, Parts: []message.ContentPart{message.TextContent{Text: "msg2"}}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when appending with invalid leafID, got nil")
|
||||
}
|
||||
|
||||
// Restore valid leafID
|
||||
tm.leafID = id1
|
||||
|
||||
// Append should succeed now
|
||||
_, err = tm.AppendMessage(message.Message{Role: message.RoleAssistant, Parts: []message.ContentPart{message.TextContent{Text: "msg3"}}})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to append msg3 after restoring leafID: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildContextHandlesCycleGracefully tests that BuildContext handles
|
||||
// cycles gracefully by truncating the branch.
|
||||
func TestBuildContextHandlesCycleGracefully(t *testing.T) {
|
||||
tm := InMemoryTreeSession("/test")
|
||||
|
||||
// Create messages
|
||||
id1, _ := tm.AppendMessage(message.Message{Role: message.RoleUser, Parts: []message.ContentPart{message.TextContent{Text: "msg1"}}})
|
||||
_, _ = tm.AppendMessage(message.Message{Role: message.RoleAssistant, Parts: []message.ContentPart{message.TextContent{Text: "msg2"}}})
|
||||
id3, _ := tm.AppendMessage(message.Message{Role: message.RoleUser, Parts: []message.ContentPart{message.TextContent{Text: "msg3"}}})
|
||||
|
||||
// Verify normal case works
|
||||
msgs, _, _ := tm.BuildContext()
|
||||
if len(msgs) != 3 {
|
||||
t.Fatalf("expected 3 messages, got %d", len(msgs))
|
||||
}
|
||||
|
||||
// Simulate cycle: set msg1's parent to msg3
|
||||
for _, entry := range tm.entries {
|
||||
if e, ok := entry.(*MessageEntry); ok && e.ID == id1 {
|
||||
e.ParentID = id3
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// BuildContext should handle cycle gracefully (getBranchLocked has cycle detection)
|
||||
msgs, _, _ = tm.BuildContext()
|
||||
// Should only include messages from the cycle: msg3, msg2, msg1
|
||||
// (msg3 is leaf, walks to msg2 -> msg1 -> msg3 (cycle detected, stops))
|
||||
if len(msgs) != 3 {
|
||||
t.Fatalf("expected 3 messages in cycle case, got %d: %+v", len(msgs), msgs)
|
||||
}
|
||||
}
|
||||
+232
-103
@@ -63,6 +63,11 @@ type TreeManager struct {
|
||||
|
||||
// file is the open file handle for appending entries. Nil for in-memory.
|
||||
file *os.File
|
||||
|
||||
// writer is a buffered writer wrapping file. Writes go through this
|
||||
// buffer and are flushed to disk at explicit sync points (after each
|
||||
// public Append* call, in Close, etc.) to reduce syscall overhead.
|
||||
writer *bufio.Writer
|
||||
}
|
||||
|
||||
// --- Constructors ---
|
||||
@@ -105,11 +110,16 @@ func CreateTreeSession(cwd string) (*TreeManager, error) {
|
||||
return nil, fmt.Errorf("failed to create session file: %w", err)
|
||||
}
|
||||
tm.file = f
|
||||
tm.writer = bufio.NewWriter(f)
|
||||
|
||||
if err := tm.writeEntry(&header); err != nil {
|
||||
_ = f.Close()
|
||||
return nil, fmt.Errorf("failed to write session header: %w", err)
|
||||
}
|
||||
if err := tm.flushLocked(); err != nil {
|
||||
_ = f.Close()
|
||||
return nil, fmt.Errorf("failed to flush session header: %w", err)
|
||||
}
|
||||
|
||||
return tm, nil
|
||||
}
|
||||
@@ -150,6 +160,7 @@ func (tm *TreeManager) ForkToNewSession(cwd string, targetID string) (*TreeManag
|
||||
return nil, fmt.Errorf("failed to recreate session file: %w", err)
|
||||
}
|
||||
newTm.file = f
|
||||
newTm.writer = bufio.NewWriter(f)
|
||||
|
||||
if err := newTm.writeEntry(&newTm.header); err != nil {
|
||||
_ = f.Close()
|
||||
@@ -289,6 +300,12 @@ func (tm *TreeManager) ForkToNewSession(cwd string, targetID string) (*TreeManag
|
||||
}
|
||||
}
|
||||
|
||||
// Flush all buffered writes from the fork in a single syscall.
|
||||
if err := newTm.flushLocked(); err != nil {
|
||||
_ = f.Close()
|
||||
return nil, fmt.Errorf("failed to flush forked session: %w", err)
|
||||
}
|
||||
|
||||
// Set the leaf to the last entry in the new session.
|
||||
newTm.leafID = prevNewID
|
||||
|
||||
@@ -365,12 +382,16 @@ func OpenTreeSession(path string) (*TreeManager, error) {
|
||||
tm.leafID = tm.EntryID(tm.entries[len(tm.entries)-1])
|
||||
}
|
||||
|
||||
// Validate tree integrity and log diagnostics
|
||||
tm.LogTreeDiagnostics()
|
||||
|
||||
// Open file for appending.
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open session file for append: %w", err)
|
||||
}
|
||||
tm.file = f
|
||||
tm.writer = bufio.NewWriter(f)
|
||||
|
||||
return tm, nil
|
||||
}
|
||||
@@ -410,6 +431,12 @@ func (tm *TreeManager) AppendMessage(msg message.Message) (string, error) {
|
||||
tm.mu.Lock()
|
||||
defer tm.mu.Unlock()
|
||||
|
||||
// Validate parent chain before appending to detect/prevent cycles
|
||||
// that could be caused by external file corruption or race conditions.
|
||||
if err := tm.validateParentChainLocked(tm.leafID, ""); err != nil {
|
||||
return "", fmt.Errorf("parent chain validation failed: %w", err)
|
||||
}
|
||||
|
||||
entry, err := NewMessageEntry(tm.leafID, msg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -418,6 +445,9 @@ func (tm *TreeManager) AppendMessage(msg message.Message) (string, error) {
|
||||
if err := tm.appendAndPersist(entry); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := tm.flushLocked(); err != nil {
|
||||
return "", fmt.Errorf("failed to flush message: %w", err)
|
||||
}
|
||||
|
||||
tm.leafID = entry.ID
|
||||
return entry.ID, nil
|
||||
@@ -428,11 +458,6 @@ func (tm *TreeManager) AppendLLMMessage(msg fantasy.Message) (string, error) {
|
||||
return tm.AppendMessage(message.FromLLMMessage(msg))
|
||||
}
|
||||
|
||||
// Deprecated: Use AppendLLMMessage instead.
|
||||
func (tm *TreeManager) AppendFantasyMessage(msg fantasy.Message) (string, error) {
|
||||
return tm.AppendLLMMessage(msg)
|
||||
}
|
||||
|
||||
// AppendModelChange records a model/provider change.
|
||||
func (tm *TreeManager) AppendModelChange(provider, modelID string) (string, error) {
|
||||
tm.mu.Lock()
|
||||
@@ -442,6 +467,9 @@ func (tm *TreeManager) AppendModelChange(provider, modelID string) (string, erro
|
||||
if err := tm.appendAndPersist(entry); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := tm.flushLocked(); err != nil {
|
||||
return "", fmt.Errorf("failed to flush model change: %w", err)
|
||||
}
|
||||
|
||||
tm.leafID = entry.ID
|
||||
return entry.ID, nil
|
||||
@@ -456,6 +484,9 @@ func (tm *TreeManager) AppendBranchSummary(fromID, summary string) (string, erro
|
||||
if err := tm.appendAndPersist(entry); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := tm.flushLocked(); err != nil {
|
||||
return "", fmt.Errorf("failed to flush branch summary: %w", err)
|
||||
}
|
||||
|
||||
tm.leafID = entry.ID
|
||||
return entry.ID, nil
|
||||
@@ -470,6 +501,9 @@ func (tm *TreeManager) AppendLabel(targetID, label string) (string, error) {
|
||||
if err := tm.appendAndPersist(entry); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := tm.flushLocked(); err != nil {
|
||||
return "", fmt.Errorf("failed to flush label: %w", err)
|
||||
}
|
||||
|
||||
tm.labels[targetID] = label
|
||||
tm.leafID = entry.ID
|
||||
@@ -485,6 +519,9 @@ func (tm *TreeManager) AppendSessionInfo(name string) (string, error) {
|
||||
if err := tm.appendAndPersist(entry); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := tm.flushLocked(); err != nil {
|
||||
return "", fmt.Errorf("failed to flush session info: %w", err)
|
||||
}
|
||||
|
||||
tm.sessionName = name
|
||||
tm.leafID = entry.ID
|
||||
@@ -501,6 +538,9 @@ func (tm *TreeManager) AppendExtensionData(extType, data string) (string, error)
|
||||
if err := tm.appendAndPersist(entry); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := tm.flushLocked(); err != nil {
|
||||
return "", fmt.Errorf("failed to flush extension data: %w", err)
|
||||
}
|
||||
|
||||
tm.leafID = entry.ID
|
||||
return entry.ID, nil
|
||||
@@ -518,6 +558,13 @@ func (tm *TreeManager) AppendCompaction(summary, firstKeptEntryID string, tokens
|
||||
tm.mu.Lock()
|
||||
defer tm.mu.Unlock()
|
||||
|
||||
// Validate that firstKeptEntryID exists if provided
|
||||
if firstKeptEntryID != "" {
|
||||
if _, ok := tm.index[firstKeptEntryID]; !ok {
|
||||
return "", fmt.Errorf("first kept entry %q does not exist", firstKeptEntryID)
|
||||
}
|
||||
}
|
||||
|
||||
// The compaction entry has no parent, making it a new "root" for the
|
||||
// post-compaction branch. This ensures old compacted messages are not
|
||||
// traversed when walking from the current leaf.
|
||||
@@ -525,6 +572,9 @@ func (tm *TreeManager) AppendCompaction(summary, firstKeptEntryID string, tokens
|
||||
if err := tm.appendAndPersist(entry); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := tm.flushLocked(); err != nil {
|
||||
return "", fmt.Errorf("failed to flush compaction: %w", err)
|
||||
}
|
||||
|
||||
tm.leafID = entry.ID
|
||||
return entry.ID, nil
|
||||
@@ -700,9 +750,17 @@ func (tm *TreeManager) BuildContext() (messages []fantasy.Message, provider stri
|
||||
}
|
||||
}
|
||||
|
||||
// If there is a compaction, inject the summary first and collect
|
||||
// the kept messages starting from FirstKeptEntryID (since the
|
||||
// compaction entry's parent chain doesn't include them).
|
||||
// If there is a compaction, inject the summary first, then the
|
||||
// preserved "kept" messages (chronologically before the compaction),
|
||||
// then the post-compaction messages (chronologically after).
|
||||
//
|
||||
// Order matters: the kept messages must come BEFORE the post-compaction
|
||||
// branch so the LLM sees the conversation in chronological order. If the
|
||||
// kept messages were appended last, the latest user message in the
|
||||
// current branch would be followed by an older kept user message,
|
||||
// breaking the strict user/assistant alternation that providers expect
|
||||
// and causing the model to respond as if the previous turn never
|
||||
// happened.
|
||||
if lastCompaction != nil {
|
||||
messages = append(messages, fantasy.Message{
|
||||
Role: fantasy.MessageRoleSystem,
|
||||
@@ -713,49 +771,10 @@ func (tm *TreeManager) BuildContext() (messages []fantasy.Message, provider stri
|
||||
},
|
||||
})
|
||||
|
||||
// Collect entries from the compaction entry itself (at compactionIndex)
|
||||
// and any entries before it in the branch (newer messages).
|
||||
for i := compactionIndex; i < len(branch); i++ {
|
||||
entry := branch[i]
|
||||
switch e := entry.(type) {
|
||||
case *MessageEntry:
|
||||
msg, err := e.ToMessage()
|
||||
if err != nil {
|
||||
continue // skip malformed entries
|
||||
}
|
||||
msgs := msg.ToLLMMessages()
|
||||
messages = append(messages, msgs...)
|
||||
|
||||
case *BranchSummaryEntry:
|
||||
// Convert branch summary to a user message for context.
|
||||
if e.Summary != "" {
|
||||
messages = append(messages, fantasy.Message{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{
|
||||
Text: fmt.Sprintf("[Branch context: %s]", e.Summary),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
case *ModelChangeEntry:
|
||||
provider = e.Provider
|
||||
modelID = e.ModelID
|
||||
|
||||
case *CompactionEntry:
|
||||
// Already handled above (summary injected).
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Now collect the kept messages starting from FirstKeptEntryID.
|
||||
// These are not in the current branch because the compaction entry
|
||||
// is parented to the first kept entry's parent, not the first kept entry.
|
||||
// We iterate through entries in order (not using getBranchLocked) to avoid
|
||||
// walking back to old compacted messages.
|
||||
// We stop when we reach the compaction entry to avoid double-counting
|
||||
// messages that were added after the compaction.
|
||||
// Step 1: collect the kept messages starting from FirstKeptEntryID.
|
||||
// These are not on the current branch (the compaction entry is a
|
||||
// new root with no parent), so we iterate tm.entries in append order
|
||||
// and stop when we reach the compaction entry itself.
|
||||
if lastCompaction.FirstKeptEntryID != "" {
|
||||
found := false
|
||||
for _, entry := range tm.entries {
|
||||
@@ -770,13 +789,12 @@ func (tm *TreeManager) BuildContext() (messages []fantasy.Message, provider stri
|
||||
}
|
||||
}
|
||||
|
||||
// Stop when we reach the compaction entry itself.
|
||||
// Messages after the compaction are collected from the branch walk above.
|
||||
// Stop when we reach the compaction entry itself; messages
|
||||
// after it are collected from the branch walk below.
|
||||
if entryID == lastCompaction.ID {
|
||||
break
|
||||
}
|
||||
|
||||
// Process this kept entry.
|
||||
switch e := entry.(type) {
|
||||
case *MessageEntry:
|
||||
msg, err := e.ToMessage()
|
||||
@@ -805,6 +823,42 @@ func (tm *TreeManager) BuildContext() (messages []fantasy.Message, provider stri
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: collect entries on the current branch after the compaction
|
||||
// entry (these are post-compaction messages). The compaction entry
|
||||
// itself is skipped — its summary was already injected above.
|
||||
for i := compactionIndex; i < len(branch); i++ {
|
||||
entry := branch[i]
|
||||
switch e := entry.(type) {
|
||||
case *MessageEntry:
|
||||
msg, err := e.ToMessage()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
msgs := msg.ToLLMMessages()
|
||||
messages = append(messages, msgs...)
|
||||
|
||||
case *BranchSummaryEntry:
|
||||
if e.Summary != "" {
|
||||
messages = append(messages, fantasy.Message{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{
|
||||
Text: fmt.Sprintf("[Branch context: %s]", e.Summary),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
case *ModelChangeEntry:
|
||||
provider = e.Provider
|
||||
modelID = e.ModelID
|
||||
|
||||
case *CompactionEntry:
|
||||
// Summary already injected above.
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return messages, provider, modelID
|
||||
}
|
||||
|
||||
@@ -910,11 +964,31 @@ func (tm *TreeManager) IsEmpty() bool {
|
||||
return tm.MessageCount() == 0
|
||||
}
|
||||
|
||||
// Close closes the underlying file handle.
|
||||
// Flush writes any buffered data to the underlying file.
|
||||
func (tm *TreeManager) Flush() error {
|
||||
tm.mu.Lock()
|
||||
defer tm.mu.Unlock()
|
||||
return tm.flushLocked()
|
||||
}
|
||||
|
||||
// flushLocked writes buffered data to disk. Caller must hold the lock.
|
||||
func (tm *TreeManager) flushLocked() error {
|
||||
if tm.writer != nil {
|
||||
return tm.writer.Flush()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close flushes any buffered writes and closes the underlying file handle.
|
||||
func (tm *TreeManager) Close() error {
|
||||
tm.mu.Lock()
|
||||
defer tm.mu.Unlock()
|
||||
if tm.file != nil {
|
||||
// Flush buffered data before closing.
|
||||
if tm.writer != nil {
|
||||
_ = tm.writer.Flush()
|
||||
tm.writer = nil
|
||||
}
|
||||
err := tm.file.Close()
|
||||
tm.file = nil
|
||||
return err
|
||||
@@ -955,44 +1029,22 @@ func (tm *TreeManager) GetContextEntryIDs() []string {
|
||||
|
||||
var ids []string
|
||||
|
||||
// If there's a compaction, we need to collect IDs from:
|
||||
// 1. Entries after the compaction entry in the branch (newer messages)
|
||||
// 2. Entries from FirstKeptEntryID onwards (kept messages)
|
||||
// If there's a compaction, we collect IDs in the same order as
|
||||
// BuildContext: [summary placeholder, kept messages, post-compaction
|
||||
// messages]. This ordering must stay in sync with BuildContext so a
|
||||
// cut-point index can be mapped back to the correct entry ID.
|
||||
if lastCompaction != nil {
|
||||
// Placeholder for the summary system message (no entry ID).
|
||||
ids = append(ids, "")
|
||||
|
||||
// Collect IDs from entries after the compaction entry (newer messages).
|
||||
for i := compactionIndex + 1; i < len(branch); i++ {
|
||||
entry := branch[i]
|
||||
switch e := entry.(type) {
|
||||
case *MessageEntry:
|
||||
msg, err := e.ToMessage()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
msgs := msg.ToLLMMessages()
|
||||
for range msgs {
|
||||
ids = append(ids, e.ID)
|
||||
}
|
||||
|
||||
case *BranchSummaryEntry:
|
||||
if e.Summary != "" {
|
||||
ids = append(ids, e.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect IDs from the kept messages starting at FirstKeptEntryID.
|
||||
// We iterate through entries in order (not using getBranchLocked) to avoid
|
||||
// walking back to old compacted messages.
|
||||
// We stop when we reach the compaction entry to avoid double-counting.
|
||||
// Step 1: IDs of the kept messages starting at FirstKeptEntryID.
|
||||
// Iterate tm.entries in append order and stop at the compaction
|
||||
// entry to avoid double-counting post-compaction messages.
|
||||
if lastCompaction.FirstKeptEntryID != "" {
|
||||
found := false
|
||||
for _, entry := range tm.entries {
|
||||
entryID := tm.EntryID(entry)
|
||||
|
||||
// Skip entries until we reach the first kept entry.
|
||||
if !found {
|
||||
if entryID == lastCompaction.FirstKeptEntryID {
|
||||
found = true
|
||||
@@ -1001,7 +1053,6 @@ func (tm *TreeManager) GetContextEntryIDs() []string {
|
||||
}
|
||||
}
|
||||
|
||||
// Stop when we reach the compaction entry itself.
|
||||
if entryID == lastCompaction.ID {
|
||||
break
|
||||
}
|
||||
@@ -1025,6 +1076,28 @@ func (tm *TreeManager) GetContextEntryIDs() []string {
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: IDs of entries after the compaction entry on the current
|
||||
// branch (post-compaction messages).
|
||||
for i := compactionIndex + 1; i < len(branch); i++ {
|
||||
entry := branch[i]
|
||||
switch e := entry.(type) {
|
||||
case *MessageEntry:
|
||||
msg, err := e.ToMessage()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
msgs := msg.ToLLMMessages()
|
||||
for range msgs {
|
||||
ids = append(ids, e.ID)
|
||||
}
|
||||
|
||||
case *BranchSummaryEntry:
|
||||
if e.Summary != "" {
|
||||
ids = append(ids, e.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
@@ -1074,18 +1147,22 @@ func (tm *TreeManager) GetLastCompaction() *CompactionEntry {
|
||||
|
||||
// AddLLMMessages appends multiple LLM messages as entries. This is
|
||||
// used when syncing from the agent's ConversationMessages after a step.
|
||||
// All entries are buffered and flushed to disk in a single batch.
|
||||
func (tm *TreeManager) AddLLMMessages(msgs []fantasy.Message) error {
|
||||
tm.mu.Lock()
|
||||
defer tm.mu.Unlock()
|
||||
|
||||
for _, msg := range msgs {
|
||||
if _, err := tm.AppendLLMMessage(msg); err != nil {
|
||||
entry, err := NewMessageEntry(tm.leafID, message.FromLLMMessage(msg))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tm.appendAndPersist(entry); err != nil {
|
||||
return err
|
||||
}
|
||||
tm.leafID = entry.ID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deprecated: Use AddLLMMessages instead.
|
||||
func (tm *TreeManager) AddFantasyMessages(msgs []fantasy.Message) error {
|
||||
return tm.AddLLMMessages(msgs)
|
||||
return tm.flushLocked()
|
||||
}
|
||||
|
||||
// GetLLMMessages builds the context and returns just the messages.
|
||||
@@ -1095,11 +1172,6 @@ func (tm *TreeManager) GetLLMMessages() []fantasy.Message {
|
||||
return msgs
|
||||
}
|
||||
|
||||
// Deprecated: Use GetLLMMessages instead.
|
||||
func (tm *TreeManager) GetFantasyMessages() []fantasy.Message {
|
||||
return tm.GetLLMMessages()
|
||||
}
|
||||
|
||||
// --- Internal helpers ---
|
||||
|
||||
// addEntryToIndex adds an entry to the in-memory indices.
|
||||
@@ -1132,12 +1204,20 @@ func (tm *TreeManager) appendAndPersist(entry any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeEntry serializes an entry and appends it as a line to the file.
|
||||
// writeEntry serializes an entry and appends it to the buffered writer.
|
||||
// The data is not flushed to disk until flushLocked is called.
|
||||
func (tm *TreeManager) writeEntry(entry any) error {
|
||||
data, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal entry: %w", err)
|
||||
}
|
||||
if tm.writer != nil {
|
||||
if _, err := tm.writer.Write(data); err != nil {
|
||||
return err
|
||||
}
|
||||
return tm.writer.WriteByte('\n')
|
||||
}
|
||||
// Fallback for direct file writes (shouldn't happen in normal flow).
|
||||
data = append(data, '\n')
|
||||
_, err = tm.file.Write(data)
|
||||
return err
|
||||
@@ -1213,12 +1293,32 @@ func (tm *TreeManager) getBranchLocked(fromID string) []any {
|
||||
}
|
||||
|
||||
// buildTreeNode recursively builds a TreeNode from an entry ID.
|
||||
// It includes a depth limit to prevent infinite recursion in case of
|
||||
// corrupted parent-child relationships.
|
||||
func (tm *TreeManager) buildTreeNode(id string) *TreeNode {
|
||||
return tm.buildTreeNodeDepth(id, 0, make(map[string]bool))
|
||||
}
|
||||
|
||||
// buildTreeNodeDepth is the internal implementation with depth tracking.
|
||||
func (tm *TreeManager) buildTreeNodeDepth(id string, depth int, visited map[string]bool) *TreeNode {
|
||||
const maxDepth = 1000
|
||||
if depth > maxDepth {
|
||||
// Cycle or extremely deep tree detected, stop recursing
|
||||
return nil
|
||||
}
|
||||
if visited[id] {
|
||||
// Cycle detected, stop recursing
|
||||
return nil
|
||||
}
|
||||
|
||||
entry, ok := tm.index[id]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
visited[id] = true
|
||||
defer delete(visited, id)
|
||||
|
||||
node := &TreeNode{
|
||||
Entry: entry,
|
||||
ID: id,
|
||||
@@ -1226,7 +1326,7 @@ func (tm *TreeManager) buildTreeNode(id string) *TreeNode {
|
||||
}
|
||||
|
||||
for _, childID := range tm.childIndex[id] {
|
||||
child := tm.buildTreeNode(childID)
|
||||
child := tm.buildTreeNodeDepth(childID, depth+1, visited)
|
||||
if child != nil {
|
||||
node.Children = append(node.Children, child)
|
||||
}
|
||||
@@ -1238,15 +1338,44 @@ func (tm *TreeManager) buildTreeNode(id string) *TreeNode {
|
||||
// --- Path conventions ---
|
||||
|
||||
// DefaultSessionDir returns the default session storage directory for a cwd.
|
||||
// Convention: ~/.kit/sessions/--<cwd-path>--/
|
||||
// Convention: ~/.kit/sessions/<encoded-cwd>, where path separators are
|
||||
// encoded as "--" with no leading or trailing dashes — e.g.
|
||||
// /home/user/proj becomes home--user--proj. See encodeCwdForDir for the
|
||||
// full encoding rules (including Windows path handling).
|
||||
func DefaultSessionDir(cwd string) string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
home = "."
|
||||
}
|
||||
// Convert path separators to double dashes.
|
||||
safeCwd := strings.ReplaceAll(cwd, string(filepath.Separator), "--")
|
||||
return filepath.Join(home, ".kit", "sessions", encodeCwdForDir(cwd))
|
||||
}
|
||||
|
||||
// encodeCwdForDir converts a working-directory path into a single, filesystem-
|
||||
// safe directory name. Path separators are replaced with double dashes and
|
||||
// characters that are illegal in Windows directory names — most importantly
|
||||
// the colon that follows the drive letter (e.g. `C:\foo` → `C--foo`) — are
|
||||
// stripped. The result is identical to the previous Unix-only encoding for
|
||||
// paths that do not contain such characters, so existing session directories
|
||||
// are preserved.
|
||||
func encodeCwdForDir(cwd string) string {
|
||||
// Convert both `/` and `\` to double dashes so encoding is stable across
|
||||
// platforms and remains correct on Windows where `filepath.Separator`
|
||||
// would otherwise miss forward-slash style paths.
|
||||
safeCwd := strings.ReplaceAll(cwd, "\\", "--")
|
||||
safeCwd = strings.ReplaceAll(safeCwd, "/", "--")
|
||||
// Remove leading separator replacement.
|
||||
safeCwd = strings.TrimPrefix(safeCwd, "--")
|
||||
return filepath.Join(home, ".kit", "sessions", safeCwd)
|
||||
// Strip characters that are illegal in directory names on Windows
|
||||
// (`< > : " | ? *`). On Unix these characters are legal but rare in
|
||||
// practice; stripping them keeps the encoding portable.
|
||||
replacer := strings.NewReplacer(
|
||||
":", "",
|
||||
"<", "",
|
||||
">", "",
|
||||
"\"", "",
|
||||
"|", "",
|
||||
"?", "",
|
||||
"*", "",
|
||||
)
|
||||
return replacer.Replace(safeCwd)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
// ValidateParentChain checks that the parent ID points to an existing entry
|
||||
// and that appending this entry would not create a cycle. This should be called
|
||||
// before appending any entry to the tree.
|
||||
// Returns an error if the parent is invalid or would create a cycle.
|
||||
func (tm *TreeManager) ValidateParentChain(parentID string, newEntryID string) error {
|
||||
if parentID == "" {
|
||||
// Empty parent is valid (root entry)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check that parent exists
|
||||
if _, ok := tm.index[parentID]; !ok {
|
||||
return fmt.Errorf("parent entry %q does not exist in index", parentID)
|
||||
}
|
||||
|
||||
// Check that we're not creating a cycle by walking up the parent chain
|
||||
// from parentID and ensuring we don't hit newEntryID (or any node that
|
||||
// has newEntryID as an ancestor, but since newEntryID is new, just check
|
||||
// that parentID isn't newEntryID, which it can't be since we check existence)
|
||||
visited := make(map[string]bool)
|
||||
current := parentID
|
||||
for current != "" {
|
||||
if visited[current] {
|
||||
return fmt.Errorf("existing cycle detected at entry %q", current)
|
||||
}
|
||||
visited[current] = true
|
||||
|
||||
// Safety check: if somehow we reach the new entry ID, that's a cycle
|
||||
if current == newEntryID {
|
||||
return fmt.Errorf("would create cycle: entry %q cannot be its own ancestor", newEntryID)
|
||||
}
|
||||
|
||||
entry, ok := tm.index[current]
|
||||
if !ok {
|
||||
return fmt.Errorf("broken parent chain: entry %q not found", current)
|
||||
}
|
||||
current = tm.entryParentID(entry)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DetectCycle walks the parent chain from the given entry ID and returns true
|
||||
// if a cycle is detected. This is used for diagnostics.
|
||||
func (tm *TreeManager) DetectCycle(fromID string) (cycleDetected bool, cycleEntry string) {
|
||||
visited := make(map[string]bool)
|
||||
current := fromID
|
||||
for current != "" {
|
||||
if visited[current] {
|
||||
return true, current
|
||||
}
|
||||
visited[current] = true
|
||||
entry, ok := tm.index[current]
|
||||
if !ok {
|
||||
return false, ""
|
||||
}
|
||||
current = tm.entryParentID(entry)
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// LogTreeDiagnostics logs information about the tree structure for debugging.
|
||||
// Call this after OpenTreeSession or when anomalies are detected.
|
||||
func (tm *TreeManager) LogTreeDiagnostics() {
|
||||
tm.mu.RLock()
|
||||
defer tm.mu.RUnlock()
|
||||
|
||||
log.Printf("[TreeManager] Entry count: %d, Leaf ID: %s", len(tm.entries), tm.leafID)
|
||||
|
||||
// Check for cycles from leaf
|
||||
if tm.leafID != "" {
|
||||
if cycle, entry := tm.detectCycleLocked(tm.leafID); cycle {
|
||||
log.Printf("[TreeManager] WARNING: Cycle detected in tree at entry %s", entry)
|
||||
}
|
||||
}
|
||||
|
||||
// Count entries by type
|
||||
counts := make(map[EntryType]int)
|
||||
for _, entry := range tm.entries {
|
||||
var et EntryType
|
||||
switch e := entry.(type) {
|
||||
case *MessageEntry:
|
||||
et = e.Type
|
||||
case *ModelChangeEntry:
|
||||
et = e.Type
|
||||
case *BranchSummaryEntry:
|
||||
et = e.Type
|
||||
case *LabelEntry:
|
||||
et = e.Type
|
||||
case *SessionInfoEntry:
|
||||
et = e.Type
|
||||
case *ExtensionDataEntry:
|
||||
et = e.Type
|
||||
case *CompactionEntry:
|
||||
et = e.Type
|
||||
default:
|
||||
et = "unknown"
|
||||
}
|
||||
counts[et]++
|
||||
}
|
||||
log.Printf("[TreeManager] Entry types: %+v", counts)
|
||||
}
|
||||
|
||||
// detectCycleLocked is the internal version of DetectCycle (must hold read lock)
|
||||
func (tm *TreeManager) detectCycleLocked(fromID string) (bool, string) {
|
||||
visited := make(map[string]bool)
|
||||
current := fromID
|
||||
for current != "" {
|
||||
if visited[current] {
|
||||
return true, current
|
||||
}
|
||||
visited[current] = true
|
||||
entry, ok := tm.index[current]
|
||||
if !ok {
|
||||
return false, ""
|
||||
}
|
||||
current = tm.entryParentID(entry)
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// validateParentChainLocked is the internal version used by append methods.
|
||||
// Must be called with the write lock held.
|
||||
func (tm *TreeManager) validateParentChainLocked(parentID string, newEntryID string) error {
|
||||
if parentID == "" {
|
||||
return nil
|
||||
}
|
||||
if _, ok := tm.index[parentID]; !ok {
|
||||
return fmt.Errorf("parent entry %q does not exist", parentID)
|
||||
}
|
||||
// Check for existing cycles in the parent chain
|
||||
if cycle, entry := tm.detectCycleLocked(parentID); cycle {
|
||||
return fmt.Errorf("existing cycle detected at entry %q in parent chain", entry)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -18,8 +18,11 @@ type PromptTemplate struct {
|
||||
Variables []string
|
||||
}
|
||||
|
||||
// variableRe matches {{variable_name}} placeholders.
|
||||
var variableRe = regexp.MustCompile(`\{\{(\w+)\}\}`)
|
||||
// variableRe matches {{variable_name}} placeholders, tolerating surrounding
|
||||
// whitespace inside the braces (e.g. {{ name }}). This is the canonical
|
||||
// template grammar shared by skill prompts and the extension template API
|
||||
// (pkg/kit ParseTemplate/RenderTemplate delegate here).
|
||||
var variableRe = regexp.MustCompile(`\{\{\s*(\w+)\s*\}\}`)
|
||||
|
||||
// NewPromptTemplate creates a PromptTemplate, automatically extracting
|
||||
// variable names from {{...}} placeholders in content.
|
||||
@@ -50,11 +53,13 @@ func LoadPromptTemplate(path string) (*PromptTemplate, error) {
|
||||
// Expand replaces all {{variable}} placeholders with values from the
|
||||
// provided map. Missing variables are left as-is (no error).
|
||||
func (t *PromptTemplate) Expand(values map[string]string) string {
|
||||
result := t.Content
|
||||
for k, v := range values {
|
||||
result = strings.ReplaceAll(result, "{{"+k+"}}", v)
|
||||
}
|
||||
return result
|
||||
return variableRe.ReplaceAllStringFunc(t.Content, func(m string) string {
|
||||
name := variableRe.FindStringSubmatch(m)[1]
|
||||
if v, ok := values[name]; ok {
|
||||
return v
|
||||
}
|
||||
return m
|
||||
})
|
||||
}
|
||||
|
||||
// ExpandStrict replaces all {{variable}} placeholders and returns an error
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/mark3labs/mcp-go/client"
|
||||
"github.com/mark3labs/mcp-go/client/transport"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
// ConnectionPoolConfig defines configuration parameters for the MCP connection pool.
|
||||
@@ -46,6 +47,7 @@ type MCPConnection struct {
|
||||
client client.MCPClient
|
||||
serverName string
|
||||
serverConfig config.MCPServerConfig
|
||||
initResult *mcp.InitializeResult // captured at handshake; nil before initialize
|
||||
lastUsed time.Time
|
||||
isHealthy bool
|
||||
errorCount int
|
||||
@@ -242,10 +244,12 @@ func (p *MCPConnectionPool) performHealthCheck(ctx context.Context, conn *MCPCon
|
||||
|
||||
// createConnection creates a new connection
|
||||
func (p *MCPConnectionPool) createConnection(ctx context.Context, serverName string, serverConfig config.MCPServerConfig) (*MCPConnection, error) {
|
||||
oauthEnabled := p.oauthFlow != nil && !serverConfig.NoOAuth
|
||||
|
||||
mcpClient, err := p.createMCPClient(ctx, serverName, serverConfig)
|
||||
if err != nil {
|
||||
// SSE transport can return OAuth error during Start()
|
||||
if p.oauthFlow != nil && IsOAuthError(err) {
|
||||
if oauthEnabled && IsOAuthError(err) {
|
||||
if flowErr := p.oauthFlow.RunAuthFlow(ctx, serverName, err); flowErr != nil {
|
||||
return nil, fmt.Errorf("OAuth authorization failed: %w", flowErr)
|
||||
}
|
||||
@@ -259,15 +263,17 @@ func (p *MCPConnectionPool) createConnection(ctx context.Context, serverName str
|
||||
}
|
||||
}
|
||||
|
||||
if err := p.initializeClient(ctx, mcpClient); err != nil {
|
||||
conn := &MCPConnection{}
|
||||
|
||||
if err := p.initializeClient(ctx, mcpClient, conn); err != nil {
|
||||
// Streamable HTTP transport returns OAuth error during Initialize()
|
||||
if p.oauthFlow != nil && IsOAuthError(err) {
|
||||
if oauthEnabled && IsOAuthError(err) {
|
||||
if flowErr := p.oauthFlow.RunAuthFlow(ctx, serverName, err); flowErr != nil {
|
||||
_ = mcpClient.Close()
|
||||
return nil, fmt.Errorf("OAuth authorization failed: %w", flowErr)
|
||||
}
|
||||
// Retry initialization after successful auth
|
||||
if err := p.initializeClient(ctx, mcpClient); err != nil {
|
||||
if err := p.initializeClient(ctx, mcpClient, conn); err != nil {
|
||||
_ = mcpClient.Close()
|
||||
return nil, err
|
||||
}
|
||||
@@ -277,15 +283,11 @@ func (p *MCPConnectionPool) createConnection(ctx context.Context, serverName str
|
||||
}
|
||||
}
|
||||
|
||||
conn := &MCPConnection{
|
||||
client: mcpClient,
|
||||
serverName: serverName,
|
||||
serverConfig: serverConfig,
|
||||
lastUsed: time.Now(),
|
||||
isHealthy: true,
|
||||
errorCount: 0,
|
||||
lastError: nil,
|
||||
}
|
||||
conn.client = mcpClient
|
||||
conn.serverName = serverName
|
||||
conn.serverConfig = serverConfig
|
||||
conn.lastUsed = time.Now()
|
||||
conn.isHealthy = true
|
||||
|
||||
if p.debugLogger != nil && p.debugLogger.IsDebugEnabled() {
|
||||
p.debugLogger.LogDebug(fmt.Sprintf("[POOL] Created connection for %s", serverName))
|
||||
@@ -304,6 +306,8 @@ func (p *MCPConnectionPool) createMCPClient(ctx context.Context, serverName stri
|
||||
return p.createSSEClient(ctx, serverConfig)
|
||||
case "streamable":
|
||||
return p.createStreamableClient(ctx, serverConfig)
|
||||
case "inprocess":
|
||||
return p.createInProcessClient(serverConfig)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported transport type '%s' for server %s", transportType, serverName)
|
||||
}
|
||||
@@ -341,49 +345,70 @@ func (p *MCPConnectionPool) createStdioClient(ctx context.Context, serverConfig
|
||||
return stdioClient, nil
|
||||
}
|
||||
|
||||
// createSSEClient creates an SSE client
|
||||
// parseHeaders parses "Key: Value" header strings into a map.
|
||||
func parseHeaders(raw []string) map[string]string {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
headers := make(map[string]string)
|
||||
for _, header := range raw {
|
||||
parts := strings.SplitN(header, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
headers[key] = value
|
||||
}
|
||||
}
|
||||
if len(headers) == 0 {
|
||||
return nil
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
// buildOAuthConfig constructs a transport.OAuthConfig from the server config
|
||||
// and the pool's OAuth flow. Returns nil if OAuth is not applicable.
|
||||
func (p *MCPConnectionPool) buildOAuthConfig(serverConfig config.MCPServerConfig) (*transport.OAuthConfig, error) {
|
||||
if p.oauthFlow == nil || serverConfig.NoOAuth {
|
||||
return nil, nil
|
||||
}
|
||||
tokenStore, err := p.createTokenStore(serverConfig.URL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create token store: %w", err)
|
||||
}
|
||||
cfg := &transport.OAuthConfig{
|
||||
RedirectURI: p.oauthFlow.handler.RedirectURI(),
|
||||
PKCEEnabled: true,
|
||||
TokenStore: tokenStore,
|
||||
}
|
||||
if serverConfig.OAuthClientID != "" {
|
||||
cfg.ClientID = serverConfig.OAuthClientID
|
||||
}
|
||||
if serverConfig.OAuthClientSecret != "" {
|
||||
cfg.ClientSecret = serverConfig.OAuthClientSecret
|
||||
}
|
||||
if len(serverConfig.OAuthScopes) > 0 {
|
||||
cfg.Scopes = serverConfig.OAuthScopes
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (p *MCPConnectionPool) createSSEClient(ctx context.Context, serverConfig config.MCPServerConfig) (client.MCPClient, error) {
|
||||
var options []transport.ClientOption
|
||||
|
||||
if len(serverConfig.Headers) > 0 {
|
||||
headers := make(map[string]string)
|
||||
for _, header := range serverConfig.Headers {
|
||||
parts := strings.SplitN(header, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
headers[key] = value
|
||||
}
|
||||
}
|
||||
if len(headers) > 0 {
|
||||
options = append(options, transport.WithHeaders(headers))
|
||||
}
|
||||
if headers := parseHeaders(serverConfig.Headers); headers != nil {
|
||||
options = append(options, transport.WithHeaders(headers))
|
||||
}
|
||||
|
||||
// Enable OAuth for remote transports when an auth handler is configured.
|
||||
// The OAuthConfig uses PKCE and the handler's redirect URI. If the server
|
||||
// config provides a pre-registered ClientID (for servers that don't support
|
||||
// dynamic client registration, e.g. GitHub), it is passed through directly.
|
||||
if p.oauthFlow != nil {
|
||||
tokenStore, tsErr := p.createTokenStore(serverConfig.URL)
|
||||
if tsErr != nil {
|
||||
return nil, fmt.Errorf("failed to create token store: %w", tsErr)
|
||||
}
|
||||
oauthCfg := transport.OAuthConfig{
|
||||
RedirectURI: p.oauthFlow.handler.RedirectURI(),
|
||||
PKCEEnabled: true,
|
||||
TokenStore: tokenStore,
|
||||
}
|
||||
if serverConfig.OAuthClientID != "" {
|
||||
oauthCfg.ClientID = serverConfig.OAuthClientID
|
||||
}
|
||||
if serverConfig.OAuthClientSecret != "" {
|
||||
oauthCfg.ClientSecret = serverConfig.OAuthClientSecret
|
||||
}
|
||||
if len(serverConfig.OAuthScopes) > 0 {
|
||||
oauthCfg.Scopes = serverConfig.OAuthScopes
|
||||
}
|
||||
options = append(options, transport.WithOAuth(oauthCfg))
|
||||
// Enable OAuth for remote transports when an auth handler is configured
|
||||
// and the server hasn't opted out via NoOAuth. Public MCP servers (e.g.
|
||||
// PubMed) set NoOAuth to skip dynamic client registration and token
|
||||
// exchange, which would otherwise fail with a 404.
|
||||
oauthCfg, err := p.buildOAuthConfig(serverConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if oauthCfg != nil {
|
||||
options = append(options, transport.WithOAuth(*oauthCfg))
|
||||
}
|
||||
|
||||
sseClient, err := client.NewSSEMCPClient(serverConfig.URL, options...)
|
||||
@@ -402,45 +427,18 @@ func (p *MCPConnectionPool) createSSEClient(ctx context.Context, serverConfig co
|
||||
func (p *MCPConnectionPool) createStreamableClient(ctx context.Context, serverConfig config.MCPServerConfig) (client.MCPClient, error) {
|
||||
var options []transport.StreamableHTTPCOption
|
||||
|
||||
if len(serverConfig.Headers) > 0 {
|
||||
headers := make(map[string]string)
|
||||
for _, header := range serverConfig.Headers {
|
||||
parts := strings.SplitN(header, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
headers[key] = value
|
||||
}
|
||||
}
|
||||
if len(headers) > 0 {
|
||||
options = append(options, transport.WithHTTPHeaders(headers))
|
||||
}
|
||||
if headers := parseHeaders(serverConfig.Headers); headers != nil {
|
||||
options = append(options, transport.WithHTTPHeaders(headers))
|
||||
}
|
||||
|
||||
// Enable OAuth for remote transports when an auth handler is configured.
|
||||
// The OAuthConfig uses PKCE and the handler's redirect URI. If the server
|
||||
// config provides a pre-registered ClientID (for servers that don't support
|
||||
// dynamic client registration, e.g. GitHub), it is passed through directly.
|
||||
if p.oauthFlow != nil {
|
||||
tokenStore, tsErr := p.createTokenStore(serverConfig.URL)
|
||||
if tsErr != nil {
|
||||
return nil, fmt.Errorf("failed to create token store: %w", tsErr)
|
||||
}
|
||||
oauthCfg := transport.OAuthConfig{
|
||||
RedirectURI: p.oauthFlow.handler.RedirectURI(),
|
||||
PKCEEnabled: true,
|
||||
TokenStore: tokenStore,
|
||||
}
|
||||
if serverConfig.OAuthClientID != "" {
|
||||
oauthCfg.ClientID = serverConfig.OAuthClientID
|
||||
}
|
||||
if serverConfig.OAuthClientSecret != "" {
|
||||
oauthCfg.ClientSecret = serverConfig.OAuthClientSecret
|
||||
}
|
||||
if len(serverConfig.OAuthScopes) > 0 {
|
||||
oauthCfg.Scopes = serverConfig.OAuthScopes
|
||||
}
|
||||
options = append(options, transport.WithHTTPOAuth(oauthCfg))
|
||||
// Enable OAuth for remote transports when an auth handler is configured
|
||||
// and the server hasn't opted out via NoOAuth.
|
||||
oauthCfg, err := p.buildOAuthConfig(serverConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if oauthCfg != nil {
|
||||
options = append(options, transport.WithHTTPOAuth(*oauthCfg))
|
||||
}
|
||||
|
||||
streamableClient, err := client.NewStreamableHttpClient(serverConfig.URL, options...)
|
||||
@@ -455,6 +453,22 @@ func (p *MCPConnectionPool) createStreamableClient(ctx context.Context, serverCo
|
||||
return streamableClient, nil
|
||||
}
|
||||
|
||||
// createInProcessClient creates an in-process MCP client that communicates
|
||||
// directly with an *server.MCPServer in the same process. No subprocess is
|
||||
// spawned and no network I/O occurs — calls go through JSON marshal →
|
||||
// MCPServer.HandleMessage → JSON unmarshal, all in-memory.
|
||||
func (p *MCPConnectionPool) createInProcessClient(serverConfig config.MCPServerConfig) (client.MCPClient, error) {
|
||||
srv, ok := serverConfig.InProcessServer.(*server.MCPServer)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("InProcessServer must be *server.MCPServer, got %T", serverConfig.InProcessServer)
|
||||
}
|
||||
inProcessClient, err := client.NewInProcessClient(srv)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create in-process client: %w", err)
|
||||
}
|
||||
return inProcessClient, nil
|
||||
}
|
||||
|
||||
// createTokenStore creates a token store for the given server URL.
|
||||
// If a custom TokenStoreFactory is configured, it is used; otherwise the
|
||||
// default file-backed token store is created.
|
||||
@@ -465,8 +479,10 @@ func (p *MCPConnectionPool) createTokenStore(serverURL string) (transport.TokenS
|
||||
return NewFileTokenStore(serverURL)
|
||||
}
|
||||
|
||||
// initializeClient initializes the client
|
||||
func (p *MCPConnectionPool) initializeClient(ctx context.Context, client client.MCPClient) error {
|
||||
// initializeClient initializes the client and captures the server's
|
||||
// initialize result on the supplied connection so callers can later
|
||||
// inspect advertised capabilities (e.g. task support).
|
||||
func (p *MCPConnectionPool) initializeClient(ctx context.Context, c client.MCPClient, conn *MCPConnection) error {
|
||||
initCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
@@ -476,12 +492,21 @@ func (p *MCPConnectionPool) initializeClient(ctx context.Context, client client.
|
||||
Name: "kit",
|
||||
Version: "1.0.0",
|
||||
}
|
||||
initRequest.Params.Capabilities = mcp.ClientCapabilities{}
|
||||
// Advertise task support so servers may return CreateTaskResult for
|
||||
// long-running tools/call requests instead of blocking the connection
|
||||
// until completion. The client is responsible for polling tasks/get and
|
||||
// tasks/result until the task reaches a terminal state.
|
||||
initRequest.Params.Capabilities = mcp.ClientCapabilities{
|
||||
Tasks: mcp.NewTasksCapability(),
|
||||
}
|
||||
|
||||
_, err := client.Initialize(initCtx, initRequest)
|
||||
initResult, err := c.Initialize(initCtx, initRequest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialization timeout or failed: %w", err)
|
||||
}
|
||||
if conn != nil {
|
||||
conn.initResult = initResult
|
||||
}
|
||||
|
||||
if p.debugLogger != nil && p.debugLogger.IsDebugEnabled() {
|
||||
p.debugLogger.LogDebug("[POOL] Initialized MCP client")
|
||||
@@ -596,6 +621,54 @@ func (c *MCPConnection) ServerName() string {
|
||||
return c.serverName
|
||||
}
|
||||
|
||||
// InitializeResult returns the result captured from the server's initialize
|
||||
// response, or nil if the connection was created before initialize completed.
|
||||
// Callers can inspect ServerCapabilities.Tasks to discover task-related
|
||||
// capability advertisements.
|
||||
func (c *MCPConnection) InitializeResult() *mcp.InitializeResult {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.initResult
|
||||
}
|
||||
|
||||
// SupportsToolTasks reports whether the server advertised support for
|
||||
// task-augmented tools/call requests. Returns false when the connection has
|
||||
// not yet completed initialization or when the server omitted task
|
||||
// capabilities.
|
||||
func (c *MCPConnection) SupportsToolTasks() bool {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return supportsToolTasksFromInit(c.initResult)
|
||||
}
|
||||
|
||||
// supportsToolTasksFromInit reports whether the supplied InitializeResult
|
||||
// advertises task-augmented tools/call support. Extracted to a free function
|
||||
// for unit testing without standing up a connection.
|
||||
func supportsToolTasksFromInit(init *mcp.InitializeResult) bool {
|
||||
if init == nil || init.Capabilities.Tasks == nil {
|
||||
return false
|
||||
}
|
||||
req := init.Capabilities.Tasks.Requests
|
||||
if req == nil || req.Tools == nil {
|
||||
return false
|
||||
}
|
||||
return req.Tools.Call != nil
|
||||
}
|
||||
|
||||
// ServerSupportsToolTasks reports whether the named server's connection
|
||||
// advertises task-augmented tools/call support. Returns false when no
|
||||
// connection exists for the server or when the server didn't advertise the
|
||||
// capability.
|
||||
func (p *MCPConnectionPool) ServerSupportsToolTasks(serverName string) bool {
|
||||
p.mu.RLock()
|
||||
conn, ok := p.connections[serverName]
|
||||
p.mu.RUnlock()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return conn.SupportsToolTasks()
|
||||
}
|
||||
|
||||
// GetClients returns a map of all MCP clients currently in the pool.
|
||||
// The map keys are server names and values are the corresponding MCP client instances.
|
||||
// The returned map is a copy and modifications won't affect the pool.
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mark3labs/kit/internal/config"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
// newTestInProcessServer creates a simple MCP server with one tool for testing.
|
||||
func newTestInProcessServer() *server.MCPServer {
|
||||
srv := server.NewMCPServer("test-server", "1.0.0",
|
||||
server.WithToolCapabilities(true),
|
||||
)
|
||||
srv.AddTool(
|
||||
mcp.NewTool("greet",
|
||||
mcp.WithDescription("Say hello"),
|
||||
mcp.WithString("name", mcp.Required(), mcp.Description("Name to greet")),
|
||||
),
|
||||
func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
name, _ := req.GetArguments()["name"].(string)
|
||||
return mcp.NewToolResultText("Hello, " + name + "!"), nil
|
||||
},
|
||||
)
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestInProcessTransportType(t *testing.T) {
|
||||
cfg := config.MCPServerConfig{
|
||||
Type: "inprocess",
|
||||
InProcessServer: newTestInProcessServer(),
|
||||
}
|
||||
if got := cfg.GetTransportType(); got != "inprocess" {
|
||||
t.Errorf("GetTransportType() = %q, want %q", got, "inprocess")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInProcessTransportTypeInferred(t *testing.T) {
|
||||
// When Type is empty but InProcessServer is set, infer "inprocess".
|
||||
cfg := config.MCPServerConfig{
|
||||
InProcessServer: newTestInProcessServer(),
|
||||
}
|
||||
if got := cfg.GetTransportType(); got != "inprocess" {
|
||||
t.Errorf("GetTransportType() = %q, want %q", got, "inprocess")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInProcessValidation(t *testing.T) {
|
||||
// Valid: InProcessServer is set.
|
||||
validCfg := &config.Config{
|
||||
MCPServers: map[string]config.MCPServerConfig{
|
||||
"test": {
|
||||
Type: "inprocess",
|
||||
InProcessServer: newTestInProcessServer(),
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := validCfg.Validate(); err != nil {
|
||||
t.Errorf("expected valid config, got error: %v", err)
|
||||
}
|
||||
|
||||
// Invalid: type is inprocess but InProcessServer is nil.
|
||||
invalidCfg := &config.Config{
|
||||
MCPServers: map[string]config.MCPServerConfig{
|
||||
"test": {
|
||||
Type: "inprocess",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := invalidCfg.Validate(); err == nil {
|
||||
t.Error("expected validation error for nil InProcessServer, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionPoolInProcessClient(t *testing.T) {
|
||||
pool := NewMCPConnectionPool(DefaultConnectionPoolConfig(), false, nil, nil)
|
||||
defer func() { _ = pool.Close() }()
|
||||
|
||||
ctx := context.Background()
|
||||
srv := newTestInProcessServer()
|
||||
|
||||
cfg := config.MCPServerConfig{
|
||||
Type: "inprocess",
|
||||
InProcessServer: srv,
|
||||
}
|
||||
|
||||
conn, err := pool.GetConnection(ctx, "test-inproc", cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("GetConnection failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify the connection is healthy and functional.
|
||||
if !conn.isHealthy {
|
||||
t.Error("expected connection to be healthy")
|
||||
}
|
||||
|
||||
// List tools to verify the connection works end-to-end.
|
||||
toolsResp, err := conn.client.ListTools(ctx, mcp.ListToolsRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("ListTools failed: %v", err)
|
||||
}
|
||||
if len(toolsResp.Tools) != 1 {
|
||||
t.Fatalf("expected 1 tool, got %d", len(toolsResp.Tools))
|
||||
}
|
||||
if toolsResp.Tools[0].Name != "greet" {
|
||||
t.Errorf("expected tool name 'greet', got %q", toolsResp.Tools[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionPoolInProcessToolExecution(t *testing.T) {
|
||||
pool := NewMCPConnectionPool(DefaultConnectionPoolConfig(), false, nil, nil)
|
||||
defer func() { _ = pool.Close() }()
|
||||
|
||||
ctx := context.Background()
|
||||
srv := newTestInProcessServer()
|
||||
|
||||
cfg := config.MCPServerConfig{
|
||||
Type: "inprocess",
|
||||
InProcessServer: srv,
|
||||
}
|
||||
|
||||
conn, err := pool.GetConnection(ctx, "test-inproc", cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("GetConnection failed: %v", err)
|
||||
}
|
||||
|
||||
// Call the tool.
|
||||
result, err := conn.client.CallTool(ctx, mcp.CallToolRequest{
|
||||
Request: mcp.Request{Method: "tools/call"},
|
||||
Params: mcp.CallToolParams{
|
||||
Name: "greet",
|
||||
Arguments: map[string]any{"name": "World"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CallTool failed: %v", err)
|
||||
}
|
||||
if result.IsError {
|
||||
t.Error("expected non-error result")
|
||||
}
|
||||
if len(result.Content) == 0 {
|
||||
t.Fatal("expected at least one content block")
|
||||
}
|
||||
text, ok := result.Content[0].(mcp.TextContent)
|
||||
if !ok {
|
||||
t.Fatalf("expected TextContent, got %T", result.Content[0])
|
||||
}
|
||||
if text.Text != "Hello, World!" {
|
||||
t.Errorf("expected 'Hello, World!', got %q", text.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPToolManagerInProcess(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
srv := newTestInProcessServer()
|
||||
|
||||
mgr := NewMCPToolManager()
|
||||
|
||||
cfg := config.MCPServerConfig{
|
||||
Type: "inprocess",
|
||||
InProcessServer: srv,
|
||||
}
|
||||
|
||||
count, err := mgr.AddServer(ctx, "myserver", cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("AddServer failed: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 tool, got %d", count)
|
||||
}
|
||||
|
||||
tools := mgr.GetTools()
|
||||
if len(tools) != 1 {
|
||||
t.Fatalf("expected 1 tool, got %d", len(tools))
|
||||
}
|
||||
if tools[0].Name != "myserver__greet" {
|
||||
t.Errorf("expected tool name 'myserver__greet', got %q", tools[0].Name)
|
||||
}
|
||||
|
||||
// Execute the tool.
|
||||
input, _ := json.Marshal(map[string]any{"name": "SDK"})
|
||||
result, err := mgr.ExecuteTool(ctx, "myserver__greet", string(input))
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteTool failed: %v", err)
|
||||
}
|
||||
if result.IsError {
|
||||
t.Error("expected non-error result")
|
||||
}
|
||||
if result.Content == "" {
|
||||
t.Error("expected non-empty result content")
|
||||
}
|
||||
|
||||
// Verify result contains our greeting.
|
||||
if !strings.Contains(result.Content, "Hello, SDK!") {
|
||||
t.Errorf("expected 'Hello, SDK!' in result, got %q", result.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionPoolInProcessInvalidServer(t *testing.T) {
|
||||
pool := NewMCPConnectionPool(DefaultConnectionPoolConfig(), false, nil, nil)
|
||||
defer func() { _ = pool.Close() }()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Pass a non-*server.MCPServer value.
|
||||
cfg := config.MCPServerConfig{
|
||||
Type: "inprocess",
|
||||
InProcessServer: "not a server",
|
||||
}
|
||||
|
||||
_, err := pool.GetConnection(ctx, "bad", cfg)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid InProcessServer type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionPoolInProcessReuse(t *testing.T) {
|
||||
pool := NewMCPConnectionPool(DefaultConnectionPoolConfig(), false, nil, nil)
|
||||
defer func() { _ = pool.Close() }()
|
||||
|
||||
ctx := context.Background()
|
||||
srv := newTestInProcessServer()
|
||||
cfg := config.MCPServerConfig{
|
||||
Type: "inprocess",
|
||||
InProcessServer: srv,
|
||||
}
|
||||
|
||||
// Get connection twice — should reuse.
|
||||
conn1, err := pool.GetConnection(ctx, "reuse-test", cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("first GetConnection failed: %v", err)
|
||||
}
|
||||
conn2, err := pool.GetConnection(ctx, "reuse-test", cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("second GetConnection failed: %v", err)
|
||||
}
|
||||
if conn1 != conn2 {
|
||||
t.Error("expected same connection object on reuse")
|
||||
}
|
||||
}
|
||||
+235
-30
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
log "github.com/charmbracelet/log"
|
||||
|
||||
"github.com/mark3labs/kit/internal/config"
|
||||
"github.com/mark3labs/mcp-go/client"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
)
|
||||
|
||||
@@ -141,6 +143,11 @@ type MCPToolManager struct {
|
||||
debug bool
|
||||
debugLogger DebugLogger
|
||||
|
||||
// taskCfg controls task-augmented tools/call execution. The zero value
|
||||
// means: auto-detect server capability, no progress callback, default
|
||||
// poll/timeout.
|
||||
taskCfg MCPTaskConfig
|
||||
|
||||
// onServerLoaded, if non-nil, is called when each server finishes loading.
|
||||
// Called with server name, tool count, and error (nil on success).
|
||||
onServerLoaded func(serverName string, toolCount int, err error)
|
||||
@@ -220,6 +227,21 @@ func (m *MCPToolManager) SetOnToolsChanged(cb func()) {
|
||||
m.onToolsChanged = cb
|
||||
}
|
||||
|
||||
// SetTaskConfig sets the task-augmented tools/call configuration. Call
|
||||
// this before LoadTools / AddServer if you want the per-server mode
|
||||
// override and progress handler to take effect for the very first call.
|
||||
// Subsequent calls replace the previous configuration wholesale.
|
||||
func (m *MCPToolManager) SetTaskConfig(cfg MCPTaskConfig) {
|
||||
m.taskCfg = cfg
|
||||
}
|
||||
|
||||
// TaskConfig returns the manager's current task-augmented tools/call
|
||||
// configuration. The zero value means: defer to per-server config and
|
||||
// auto-detected capability, with no progress callback and default polling.
|
||||
func (m *MCPToolManager) TaskConfig() MCPTaskConfig {
|
||||
return m.taskCfg
|
||||
}
|
||||
|
||||
// AddServer connects to a new MCP server at runtime and loads its tools.
|
||||
// The server's tools are immediately available to the agent after this call.
|
||||
// Returns the number of tools loaded from the server.
|
||||
@@ -551,6 +573,14 @@ func (m *MCPToolManager) loadServerTools(ctx context.Context, serverName string,
|
||||
// checks, OAuth re-authorization, and connection error tracking.
|
||||
// The inputJSON parameter is the raw JSON arguments from the LLM.
|
||||
// Returns the result content, error flag, and any execution error.
|
||||
//
|
||||
// When the per-server TasksMode resolves to "always", or to "auto" and the
|
||||
// server advertised tasks/toolCalls capability during initialize, the call
|
||||
// is augmented with TaskParams. If the server elects to respond with a
|
||||
// CreateTaskResult the manager polls tasks/get / tasks/result until the
|
||||
// task reaches a terminal state, transparently presenting the final
|
||||
// CallToolResult-equivalent content to the agent layer. Context
|
||||
// cancellation triggers a best-effort tasks/cancel.
|
||||
func (m *MCPToolManager) ExecuteTool(ctx context.Context, prefixedName, inputJSON string) (*MCPToolResult, error) {
|
||||
m.mu.Lock()
|
||||
mapping, ok := m.toolMap[prefixedName]
|
||||
@@ -582,49 +612,224 @@ func (m *MCPToolManager) ExecuteTool(ctx context.Context, prefixedName, inputJSO
|
||||
return nil, fmt.Errorf("failed to get healthy connection from pool: %w", err)
|
||||
}
|
||||
|
||||
callRequest := mcp.CallToolRequest{
|
||||
Request: mcp.Request{
|
||||
Method: "tools/call",
|
||||
},
|
||||
Params: mcp.CallToolParams{
|
||||
Name: mapping.originalName,
|
||||
Arguments: arguments,
|
||||
},
|
||||
callParams := mcp.CallToolParams{
|
||||
Name: mapping.originalName,
|
||||
Arguments: arguments,
|
||||
}
|
||||
|
||||
// Call the MCP tool using the original (unprefixed) name
|
||||
result, err := conn.client.CallTool(ctx, callRequest)
|
||||
if err != nil {
|
||||
// Handle OAuth re-authorization: token may have expired mid-session.
|
||||
if m.connectionPool.oauthFlow != nil && IsOAuthError(err) {
|
||||
if flowErr := m.connectionPool.oauthFlow.RunAuthFlow(ctx, mapping.serverName, err); flowErr != nil {
|
||||
return nil, fmt.Errorf("OAuth re-authorization failed for tool %s: %w", mapping.originalName, flowErr)
|
||||
}
|
||||
// Retry the tool call after successful re-auth.
|
||||
result, err = conn.client.CallTool(ctx, callRequest)
|
||||
if err != nil {
|
||||
m.connectionPool.HandleConnectionError(mapping.serverName, err)
|
||||
return nil, fmt.Errorf("failed to call mcp tool after re-auth: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Mark connection as unhealthy for automatic recovery
|
||||
m.connectionPool.HandleConnectionError(mapping.serverName, err)
|
||||
return nil, fmt.Errorf("failed to call mcp tool: %w", err)
|
||||
// Decide whether to augment the request with TaskParams. Modes:
|
||||
// never — never augment (synchronous-only).
|
||||
// always — always augment, even without server capability.
|
||||
// auto — augment only when the server advertised tasks/toolCalls.
|
||||
mode := m.resolveTaskMode(mapping.serverName, mapping.serverConfig)
|
||||
useTask := mode == MCPTaskModeAlways ||
|
||||
(mode == MCPTaskModeAuto && conn.SupportsToolTasks())
|
||||
if useTask {
|
||||
var ttl *int64
|
||||
if m.taskCfg.DefaultTTL > 0 {
|
||||
ms := m.taskCfg.DefaultTTL.Milliseconds()
|
||||
ttl = &ms
|
||||
}
|
||||
callParams.Task = &mcp.TaskParams{TTL: ttl}
|
||||
}
|
||||
|
||||
// Marshal the MCP result to JSON string
|
||||
marshaledResult, err := json.Marshal(result)
|
||||
// Synchronous fast path: no task augmentation. Use the upstream client
|
||||
// helper which keeps content-block typing identical to historical
|
||||
// behaviour.
|
||||
if !useTask {
|
||||
callRequest := mcp.CallToolRequest{
|
||||
Request: mcp.Request{Method: "tools/call"},
|
||||
Params: callParams,
|
||||
}
|
||||
var result *mcp.CallToolResult
|
||||
err := m.withOAuthRetry(ctx, mapping.serverName, mapping.originalName, func() error {
|
||||
var callErr error
|
||||
result, callErr = conn.client.CallTool(ctx, callRequest)
|
||||
return callErr
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalToolResult(result)
|
||||
}
|
||||
|
||||
// Task-augmented path. Bypass the upstream CallTool helper because its
|
||||
// ParseCallToolResult requires a "content" field that is absent from a
|
||||
// CreateTaskResult.
|
||||
rawClient, ok := conn.client.(*client.Client)
|
||||
if !ok {
|
||||
// Older client implementations — fall back to the synchronous shape.
|
||||
callParams.Task = nil
|
||||
callRequest := mcp.CallToolRequest{
|
||||
Request: mcp.Request{Method: "tools/call"},
|
||||
Params: callParams,
|
||||
}
|
||||
result, callErr := conn.client.CallTool(ctx, callRequest)
|
||||
if callErr != nil {
|
||||
m.connectionPool.HandleConnectionError(mapping.serverName, callErr)
|
||||
return nil, fmt.Errorf("failed to call mcp tool: %w", callErr)
|
||||
}
|
||||
return marshalToolResult(result)
|
||||
}
|
||||
|
||||
var (
|
||||
callResult *mcp.CallToolResult
|
||||
taskResult *mcp.CreateTaskResult
|
||||
)
|
||||
err = m.withOAuthRetry(ctx, mapping.serverName, mapping.originalName, func() error {
|
||||
var callErr error
|
||||
callResult, taskResult, callErr = callToolWithTask(ctx, rawClient, callParams)
|
||||
return callErr
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Server chose to answer synchronously — same shape as the no-task path.
|
||||
if callResult != nil {
|
||||
return marshalToolResult(callResult)
|
||||
}
|
||||
|
||||
// Asynchronous task path: poll until terminal, then return the result.
|
||||
if taskResult == nil {
|
||||
return nil, errors.New("mcp tools/call returned neither result nor task")
|
||||
}
|
||||
final, pollErr := pollTaskUntilTerminal(
|
||||
ctx, rawClient, mapping.serverName, taskResult.Task,
|
||||
m.taskCfg, m.taskCfg.Progress,
|
||||
)
|
||||
if pollErr != nil {
|
||||
return nil, fmt.Errorf("task execution failed: %w", pollErr)
|
||||
}
|
||||
|
||||
// Adapt TaskResultResult → CallToolResult for downstream JSON shape parity.
|
||||
return marshalToolResult(&mcp.CallToolResult{
|
||||
Content: final.Content,
|
||||
StructuredContent: final.StructuredContent,
|
||||
IsError: final.IsError,
|
||||
})
|
||||
}
|
||||
|
||||
// withOAuthRetry runs call once; when it fails with an OAuth error and an
|
||||
// OAuth flow is configured, it re-authorizes the server and retries once.
|
||||
// Connection failures are reported to the pool and wrapped uniformly. This
|
||||
// consolidates the retry/error chain shared by the synchronous and
|
||||
// task-augmented tool-call paths.
|
||||
func (m *MCPToolManager) withOAuthRetry(ctx context.Context, serverName, toolName string, call func() error) error {
|
||||
callErr := call()
|
||||
if callErr == nil {
|
||||
return nil
|
||||
}
|
||||
if m.connectionPool.oauthFlow != nil && IsOAuthError(callErr) {
|
||||
if flowErr := m.connectionPool.oauthFlow.RunAuthFlow(ctx, serverName, callErr); flowErr != nil {
|
||||
return fmt.Errorf("OAuth re-authorization failed for tool %s: %w", toolName, flowErr)
|
||||
}
|
||||
if callErr = call(); callErr != nil {
|
||||
m.connectionPool.HandleConnectionError(serverName, callErr)
|
||||
return fmt.Errorf("failed to call mcp tool after re-auth: %w", callErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
m.connectionPool.HandleConnectionError(serverName, callErr)
|
||||
return fmt.Errorf("failed to call mcp tool: %w", callErr)
|
||||
}
|
||||
|
||||
// marshalToolResult converts an MCP CallToolResult into the JSON-encoded
|
||||
// MCPToolResult shape returned to the agent.
|
||||
func marshalToolResult(result *mcp.CallToolResult) (*MCPToolResult, error) {
|
||||
if result == nil {
|
||||
return nil, errors.New("mcp tool call returned nil result")
|
||||
}
|
||||
marshaled, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal mcp tool result: %w", err)
|
||||
}
|
||||
|
||||
return &MCPToolResult{
|
||||
Content: string(marshaledResult),
|
||||
Content: string(marshaled),
|
||||
IsError: result.IsError,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveTaskMode resolves the effective task mode for a given server.
|
||||
// Programmatic overrides via SetTaskConfig take precedence over the
|
||||
// per-server TasksMode in MCPServerConfig. Empty / unknown values map to
|
||||
// MCPTaskModeAuto.
|
||||
func (m *MCPToolManager) resolveTaskMode(name string, cfg config.MCPServerConfig) MCPTaskMode {
|
||||
if m.taskCfg.PerServerMode != nil {
|
||||
if v, ok := m.taskCfg.PerServerMode[name]; ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ParseTaskMode(cfg.TasksMode)
|
||||
}
|
||||
|
||||
// ListServerTasks queries tasks/list on the named server and returns the
|
||||
// active and recent tasks the server is willing to disclose. Errors are
|
||||
// returned untouched (callers commonly ignore METHOD_NOT_FOUND when the
|
||||
// server didn't advertise tasks/list capability).
|
||||
func (m *MCPToolManager) ListServerTasks(ctx context.Context, serverName string) ([]MCPTaskInfo, error) {
|
||||
c, err := m.taskClient(serverName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err := c.ListTasks(ctx, mcp.ListTasksRequest{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tasks/list on %s: %w", serverName, err)
|
||||
}
|
||||
out := make([]MCPTaskInfo, 0, len(res.Tasks))
|
||||
for _, t := range res.Tasks {
|
||||
out = append(out, taskFromMCP(serverName, t))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetServerTask queries tasks/get for a single task on the named server.
|
||||
func (m *MCPToolManager) GetServerTask(ctx context.Context, serverName, taskID string) (MCPTaskInfo, error) {
|
||||
c, err := m.taskClient(serverName)
|
||||
if err != nil {
|
||||
return MCPTaskInfo{}, err
|
||||
}
|
||||
res, err := c.GetTask(ctx, mcp.GetTaskRequest{Params: mcp.GetTaskParams{TaskId: taskID}})
|
||||
if err != nil {
|
||||
return MCPTaskInfo{}, fmt.Errorf("tasks/get on %s: %w", serverName, err)
|
||||
}
|
||||
return taskFromMCP(serverName, res.Task), nil
|
||||
}
|
||||
|
||||
// CancelServerTask issues tasks/cancel for a task on the named server.
|
||||
// Returns the post-cancel task state when the server responded with one.
|
||||
func (m *MCPToolManager) CancelServerTask(ctx context.Context, serverName, taskID string) (MCPTaskInfo, error) {
|
||||
c, err := m.taskClient(serverName)
|
||||
if err != nil {
|
||||
return MCPTaskInfo{}, err
|
||||
}
|
||||
res, err := c.CancelTask(ctx, mcp.CancelTaskRequest{Params: mcp.CancelTaskParams{TaskId: taskID}})
|
||||
if err != nil {
|
||||
return MCPTaskInfo{}, fmt.Errorf("tasks/cancel on %s: %w", serverName, err)
|
||||
}
|
||||
return taskFromMCP(serverName, res.Task), nil
|
||||
}
|
||||
|
||||
// taskClient returns the *client.Client for a server. Tasks endpoints are
|
||||
// not part of the upstream MCPClient interface so callers must work with
|
||||
// the concrete client. Returns an error when the connection is missing
|
||||
// or backed by a non-standard client type.
|
||||
func (m *MCPToolManager) taskClient(serverName string) (*client.Client, error) {
|
||||
if m.connectionPool == nil {
|
||||
return nil, fmt.Errorf("no connection pool available")
|
||||
}
|
||||
clients := m.connectionPool.GetClients()
|
||||
raw, ok := clients[serverName]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("MCP server %q not loaded", serverName)
|
||||
}
|
||||
c, ok := raw.(*client.Client)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("MCP server %q does not support task RPCs", serverName)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// GetTools returns all loaded MCP tools from all configured MCP servers.
|
||||
// Tools are returned with their prefixed names (serverName__toolName) to ensure uniqueness.
|
||||
func (m *MCPToolManager) GetTools() []MCPTool {
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/mark3labs/mcp-go/client"
|
||||
"github.com/mark3labs/mcp-go/client/transport"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
)
|
||||
|
||||
// MCPTaskMode controls when the connection pool augments tools/call requests
|
||||
// with MCP task metadata. See https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks.
|
||||
type MCPTaskMode string
|
||||
|
||||
const (
|
||||
// MCPTaskModeAuto augments tools/call with task metadata only when the
|
||||
// server advertises tasks/toolCalls capability during initialize.
|
||||
MCPTaskModeAuto MCPTaskMode = "auto"
|
||||
// MCPTaskModeNever forces every tools/call to be issued synchronously
|
||||
// (no Task field in the request), regardless of server capability.
|
||||
MCPTaskModeNever MCPTaskMode = "never"
|
||||
// MCPTaskModeAlways always sets a Task field on the tools/call request,
|
||||
// even when the server didn't advertise task support. The server may
|
||||
// still respond synchronously; this just opts in unconditionally on
|
||||
// the client side.
|
||||
MCPTaskModeAlways MCPTaskMode = "always"
|
||||
)
|
||||
|
||||
// ParseTaskMode normalises a per-server tasks-mode string from
|
||||
// configuration. Empty input maps to MCPTaskModeAuto. Unknown values are
|
||||
// also treated as MCPTaskModeAuto so a stray config typo never breaks
|
||||
// existing flows.
|
||||
func ParseTaskMode(s string) MCPTaskMode {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "", "auto":
|
||||
return MCPTaskModeAuto
|
||||
case "never", "off", "disabled":
|
||||
return MCPTaskModeNever
|
||||
case "always", "force":
|
||||
return MCPTaskModeAlways
|
||||
default:
|
||||
return MCPTaskModeAuto
|
||||
}
|
||||
}
|
||||
|
||||
// MCPTaskInfo is the connection-layer view of an MCP Task. It mirrors the
|
||||
// upstream mcp.Task but exposes Go-native types and includes the originating
|
||||
// server name. SDK-level wrappers re-export this under public-facing names.
|
||||
type MCPTaskInfo struct {
|
||||
// Server is the configured MCP server name this task lives on.
|
||||
Server string
|
||||
// TaskID is the server-assigned identifier for the task.
|
||||
TaskID string
|
||||
// Status is the current task lifecycle state.
|
||||
Status mcp.TaskStatus
|
||||
// StatusMessage is an optional human-readable description.
|
||||
StatusMessage string
|
||||
// CreatedAt is the wall-clock time the task was created (best-effort
|
||||
// parsed from the server's ISO-8601 timestamp; zero on parse failure).
|
||||
CreatedAt time.Time
|
||||
// UpdatedAt is the wall-clock time the task was last updated (best-
|
||||
// effort parsed; zero on parse failure).
|
||||
UpdatedAt time.Time
|
||||
// TTL is the time-to-live the server intends to retain the task after
|
||||
// creation. Zero means the server did not advertise a TTL.
|
||||
TTL time.Duration
|
||||
// PollInterval is the suggested polling interval. Zero means use the
|
||||
// client's default.
|
||||
PollInterval time.Duration
|
||||
}
|
||||
|
||||
// MCPTaskProgress is emitted while the connection pool is waiting on a
|
||||
// task-augmented tool call. It provides minimal feedback for SDK consumers
|
||||
// that want to render progress widgets without subscribing to the full
|
||||
// notifications/tasks/status channel (Phase 2).
|
||||
type MCPTaskProgress struct {
|
||||
Server string
|
||||
TaskID string
|
||||
Status mcp.TaskStatus
|
||||
Message string
|
||||
}
|
||||
|
||||
// MCPTaskProgressHandler is invoked once after a task is accepted and on
|
||||
// every status transition observed by the polling loop. The final
|
||||
// invocation always carries a terminal status. Implementations must not
|
||||
// block; long work should be queued on a goroutine.
|
||||
type MCPTaskProgressHandler func(MCPTaskProgress)
|
||||
|
||||
// MCPTaskConfig configures task-aware tool execution on the manager.
|
||||
// All fields are optional; the zero value disables progress callbacks and
|
||||
// applies sensible defaults.
|
||||
type MCPTaskConfig struct {
|
||||
// PerServerMode overrides the per-server TasksMode resolved from
|
||||
// MCPServerConfig. Keys are server names. Missing entries fall back
|
||||
// to the value from config. Used by SDK consumers that want to set
|
||||
// modes programmatically.
|
||||
PerServerMode map[string]MCPTaskMode
|
||||
|
||||
// DefaultTTL is the TTL hint sent in TaskParams when augmenting a
|
||||
// tools/call. Zero means omit the TTL — let the server pick its own.
|
||||
DefaultTTL time.Duration
|
||||
|
||||
// PollInterval is the fallback interval between tasks/get requests
|
||||
// when the server does not suggest one. Zero defaults to 1 second.
|
||||
PollInterval time.Duration
|
||||
|
||||
// MaxPollInterval caps the polling interval. Zero defaults to 5 seconds.
|
||||
MaxPollInterval time.Duration
|
||||
|
||||
// Timeout is the maximum wall-clock duration to wait for a task to
|
||||
// reach a terminal state. Zero defaults to 15 minutes. Independent
|
||||
// of the per-call context deadline; whichever fires first wins.
|
||||
Timeout time.Duration
|
||||
|
||||
// Progress, if non-nil, receives every status transition observed by
|
||||
// the polling loop.
|
||||
Progress MCPTaskProgressHandler
|
||||
}
|
||||
|
||||
func (c MCPTaskConfig) resolved() MCPTaskConfig {
|
||||
if c.PollInterval <= 0 {
|
||||
c.PollInterval = 1 * time.Second
|
||||
}
|
||||
if c.MaxPollInterval <= 0 {
|
||||
c.MaxPollInterval = 5 * time.Second
|
||||
}
|
||||
if c.Timeout <= 0 {
|
||||
c.Timeout = 15 * time.Minute
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// requestIDCounter generates monotonically increasing JSON-RPC request IDs
|
||||
// for low-level tools/call invocations that bypass the upstream client's
|
||||
// ParseCallToolResult helper (necessary because that helper rejects task
|
||||
// responses for lacking a "content" field).
|
||||
//
|
||||
// The counter is process-wide rather than per-manager so multiple managers
|
||||
// or repeated calls within the same connection produce unique IDs.
|
||||
var requestIDCounter atomic.Int64
|
||||
|
||||
func nextRequestID() mcp.RequestId {
|
||||
return mcp.NewRequestId(requestIDCounter.Add(1))
|
||||
}
|
||||
|
||||
// callToolWithTask issues tools/call directly on the transport so we can
|
||||
// observe both response shapes:
|
||||
//
|
||||
// - {"content": [...], ...} — synchronous CallToolResult.
|
||||
// - {"task": {...}, ...} — asynchronous CreateTaskResult.
|
||||
//
|
||||
// On success exactly one of (callResult, taskResult) is non-nil. The
|
||||
// upstream client.CallTool helper parses the response with
|
||||
// mcp.ParseCallToolResult which requires a "content" field, so it cannot
|
||||
// be used for task-augmented calls.
|
||||
func callToolWithTask(
|
||||
ctx context.Context,
|
||||
c *client.Client,
|
||||
params mcp.CallToolParams,
|
||||
) (callResult *mcp.CallToolResult, taskResult *mcp.CreateTaskResult, err error) {
|
||||
tr := c.GetTransport()
|
||||
if tr == nil {
|
||||
return nil, nil, errors.New("mcp client has no transport")
|
||||
}
|
||||
|
||||
req := transport.JSONRPCRequest{
|
||||
JSONRPC: mcp.JSONRPC_VERSION,
|
||||
ID: nextRequestID(),
|
||||
Method: string(mcp.MethodToolsCall),
|
||||
Params: params,
|
||||
}
|
||||
|
||||
resp, sendErr := tr.SendRequest(ctx, req)
|
||||
if sendErr != nil {
|
||||
return nil, nil, sendErr
|
||||
}
|
||||
if resp.Error != nil {
|
||||
return nil, nil, resp.Error.AsError()
|
||||
}
|
||||
|
||||
// Peek at the raw result to decide which shape we got.
|
||||
var probe struct {
|
||||
Task json.RawMessage `json:"task"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
}
|
||||
raw := resp.Result
|
||||
if len(raw) == 0 {
|
||||
return nil, nil, errors.New("empty tools/call result")
|
||||
}
|
||||
if uErr := json.Unmarshal(raw, &probe); uErr != nil {
|
||||
return nil, nil, fmt.Errorf("decode tools/call result: %w", uErr)
|
||||
}
|
||||
|
||||
if len(probe.Task) > 0 && string(probe.Task) != "null" {
|
||||
// Task-augmented response.
|
||||
var ct mcp.CreateTaskResult
|
||||
if uErr := json.Unmarshal(raw, &ct); uErr != nil {
|
||||
return nil, nil, fmt.Errorf("decode CreateTaskResult: %w", uErr)
|
||||
}
|
||||
return nil, &ct, nil
|
||||
}
|
||||
|
||||
// Synchronous response — defer to the upstream parser so content blocks
|
||||
// are typed correctly (TextContent, ImageContent, ResourceLink, etc.).
|
||||
cr, pErr := mcp.ParseCallToolResult(&raw)
|
||||
if pErr != nil {
|
||||
return nil, nil, fmt.Errorf("parse CallToolResult: %w", pErr)
|
||||
}
|
||||
return cr, nil, nil
|
||||
}
|
||||
|
||||
// pollTaskUntilTerminal blocks until the task reaches a terminal status,
|
||||
// the context is cancelled, or the configured timeout elapses. On
|
||||
// cancellation it best-effort issues tasks/cancel before returning.
|
||||
func pollTaskUntilTerminal(
|
||||
ctx context.Context,
|
||||
c *client.Client,
|
||||
serverName string,
|
||||
task mcp.Task,
|
||||
cfg MCPTaskConfig,
|
||||
progress MCPTaskProgressHandler,
|
||||
) (*mcp.TaskResultResult, error) {
|
||||
cfg = cfg.resolved()
|
||||
deadline := time.Now().Add(cfg.Timeout)
|
||||
|
||||
emit := func(status mcp.TaskStatus, msg string) {
|
||||
if progress != nil {
|
||||
progress(MCPTaskProgress{Server: serverName, TaskID: task.TaskId, Status: status, Message: msg})
|
||||
}
|
||||
}
|
||||
|
||||
emit(task.Status, task.StatusMessage)
|
||||
|
||||
current := task
|
||||
interval := cfg.PollInterval
|
||||
if current.PollInterval != nil && *current.PollInterval > 0 {
|
||||
interval = time.Duration(*current.PollInterval) * time.Millisecond
|
||||
}
|
||||
if interval > cfg.MaxPollInterval {
|
||||
interval = cfg.MaxPollInterval
|
||||
}
|
||||
|
||||
for !current.Status.IsTerminal() {
|
||||
if time.Now().After(deadline) {
|
||||
cancelTaskBestEffort(c, current.TaskId)
|
||||
return nil, fmt.Errorf("task %s timed out after %s", current.TaskId, cfg.Timeout)
|
||||
}
|
||||
|
||||
// Wait between polls or abort early on context cancellation.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
cancelTaskBestEffort(c, current.TaskId)
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(interval):
|
||||
}
|
||||
|
||||
got, err := c.GetTask(ctx, mcp.GetTaskRequest{
|
||||
Params: mcp.GetTaskParams{TaskId: current.TaskId},
|
||||
})
|
||||
if err != nil {
|
||||
// Transient transport hiccup — propagate immediately. The
|
||||
// upstream agent layer treats this like any other tool error.
|
||||
return nil, fmt.Errorf("tasks/get failed: %w", err)
|
||||
}
|
||||
current = got.Task
|
||||
if current.Status != task.Status || current.StatusMessage != task.StatusMessage {
|
||||
emit(current.Status, current.StatusMessage)
|
||||
task = current
|
||||
}
|
||||
|
||||
// Honour any updated suggested poll interval, capped at the limit.
|
||||
if current.PollInterval != nil && *current.PollInterval > 0 {
|
||||
interval = min(time.Duration(*current.PollInterval)*time.Millisecond, cfg.MaxPollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// Terminal state reached. Emit one last progress event and fetch the
|
||||
// definitive tool result.
|
||||
emit(current.Status, current.StatusMessage)
|
||||
|
||||
if current.Status == mcp.TaskStatusCancelled {
|
||||
return nil, fmt.Errorf("task %s was cancelled", current.TaskId)
|
||||
}
|
||||
|
||||
res, err := fetchTaskResult(ctx, c, current.TaskId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tasks/result failed: %w", err)
|
||||
}
|
||||
if current.Status == mcp.TaskStatusFailed && res != nil && !res.IsError {
|
||||
// The server flagged the task as failed but didn't decorate the
|
||||
// result. Surface the status message so the caller still sees a
|
||||
// useful tool-error.
|
||||
return nil, fmt.Errorf("task %s failed: %s", current.TaskId, current.StatusMessage)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// fetchTaskResult issues tasks/result on the transport and parses the raw
|
||||
// response. The upstream client.TaskResult helper delegates to
|
||||
// mcp.ParseTaskResultResult which (as of mcp-go v0.51.0) looks for the
|
||||
// content array under a nested "result" key that never exists in the
|
||||
// wire format — leading to systematically empty Content. Doing the
|
||||
// parse here keeps the polling path working until that is fixed upstream.
|
||||
func fetchTaskResult(ctx context.Context, c *client.Client, taskID string) (*mcp.TaskResultResult, error) {
|
||||
tr := c.GetTransport()
|
||||
if tr == nil {
|
||||
return nil, errors.New("mcp client has no transport")
|
||||
}
|
||||
req := transport.JSONRPCRequest{
|
||||
JSONRPC: mcp.JSONRPC_VERSION,
|
||||
ID: nextRequestID(),
|
||||
Method: string(mcp.MethodTasksResult),
|
||||
Params: mcp.TaskResultParams{TaskId: taskID},
|
||||
}
|
||||
resp, err := tr.SendRequest(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Error != nil {
|
||||
return nil, resp.Error.AsError()
|
||||
}
|
||||
|
||||
// Manually decode the wire shape: {"_meta": {...}, "content": [...],
|
||||
// "structuredContent": ..., "isError": bool}.
|
||||
var shape struct {
|
||||
Meta json.RawMessage `json:"_meta"`
|
||||
Content []json.RawMessage `json:"content"`
|
||||
StructuredContent any `json:"structuredContent"`
|
||||
IsError bool `json:"isError"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Result, &shape); err != nil {
|
||||
return nil, fmt.Errorf("decode tasks/result: %w", err)
|
||||
}
|
||||
|
||||
out := &mcp.TaskResultResult{
|
||||
StructuredContent: shape.StructuredContent,
|
||||
IsError: shape.IsError,
|
||||
}
|
||||
if len(shape.Meta) > 0 && string(shape.Meta) != "null" {
|
||||
var metaMap map[string]any
|
||||
if err := json.Unmarshal(shape.Meta, &metaMap); err == nil {
|
||||
out.Meta = mcp.NewMetaFromMap(metaMap)
|
||||
}
|
||||
}
|
||||
for _, raw := range shape.Content {
|
||||
var contentMap map[string]any
|
||||
if err := json.Unmarshal(raw, &contentMap); err != nil {
|
||||
return nil, fmt.Errorf("decode content block: %w", err)
|
||||
}
|
||||
parsed, err := mcp.ParseContent(contentMap)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse content block: %w", err)
|
||||
}
|
||||
out.Content = append(out.Content, parsed)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// cancelTaskBestEffort issues tasks/cancel and ignores any error. Used on
|
||||
// context cancellation paths where the connection is already going away.
|
||||
func cancelTaskBestEffort(c *client.Client, taskID string) {
|
||||
if c == nil || taskID == "" {
|
||||
return
|
||||
}
|
||||
cancelCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, _ = c.CancelTask(cancelCtx, mcp.CancelTaskRequest{
|
||||
Params: mcp.CancelTaskParams{TaskId: taskID},
|
||||
})
|
||||
}
|
||||
|
||||
// taskFromMCP converts a wire-format mcp.Task to our richer connection-
|
||||
// layer view. Unparseable timestamps surface as the zero time.
|
||||
func taskFromMCP(serverName string, t mcp.Task) MCPTaskInfo {
|
||||
out := MCPTaskInfo{
|
||||
Server: serverName,
|
||||
TaskID: t.TaskId,
|
||||
Status: t.Status,
|
||||
StatusMessage: t.StatusMessage,
|
||||
}
|
||||
if t.CreatedAt != "" {
|
||||
if v, err := time.Parse(time.RFC3339, t.CreatedAt); err == nil {
|
||||
out.CreatedAt = v
|
||||
}
|
||||
}
|
||||
if t.LastUpdatedAt != "" {
|
||||
if v, err := time.Parse(time.RFC3339, t.LastUpdatedAt); err == nil {
|
||||
out.UpdatedAt = v
|
||||
}
|
||||
}
|
||||
if t.TTL != nil {
|
||||
out.TTL = time.Duration(*t.TTL) * time.Millisecond
|
||||
}
|
||||
if t.PollInterval != nil {
|
||||
out.PollInterval = time.Duration(*t.PollInterval) * time.Millisecond
|
||||
}
|
||||
return out
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user