feat: rate limit pools, elf tree view, permission prompts, dep updates
Rate limits: - Add PoolRPS/PoolTPM/PoolTokensMonth/PoolCostMonth pool kinds - Provider defaults for Mistral/Anthropic/OpenAI/Google (tier-aware) - Config override via [rate_limits.<provider>] TOML section - Pools auto-attached to arms on registration Elf tree view (CC-style): - Structured elf.Progress type replaces flat string channel - Tree with ├─/└─ branches, per-elf stats (tool uses, tokens) - Live activity updates: tool calls, "generating… (N chars)" - Completed elfs stay in tree with "Done (duration)" until turn ends - Suppress raw elf output from chat (tree + LLM summary instead) - Remove background elf mode (wait: false) — always wait - Truncate elf results to 2000 chars for parent context - Parallel hint in system prompt and tool description Permission prompts: - Show actual command in prompt: "bash wants to execute: find . -name '*.go'" - Compact hint in separator bar: "⚠ bash: find . | wc -l [y/n]" - PermReqMsg carries tool name + args Other: - Fix /model not updating status bar (session.Local.SetModel) - Add make targets: run, check, install - Update deps: BurntSushi/toml v1.6.0, chroma v2.23.1, x/text v0.35.0, cloud.google.com/go v0.123.0
This commit is contained in:
11
Makefile
11
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: build test lint cover clean fmt vet
|
||||
.PHONY: build run check install test lint cover clean fmt vet
|
||||
|
||||
BINARY := gnoma
|
||||
BINDIR := ./bin
|
||||
@@ -7,6 +7,15 @@ MODULE := somegit.dev/Owlibou/gnoma
|
||||
build:
|
||||
go build -o $(BINDIR)/$(BINARY) ./cmd/gnoma
|
||||
|
||||
run: build
|
||||
$(BINDIR)/$(BINARY)
|
||||
|
||||
check: fmt vet lint test
|
||||
@echo "All checks passed!"
|
||||
|
||||
install:
|
||||
go install $(MODULE)/cmd/$(BINARY)
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
|
||||
@@ -154,14 +154,19 @@ func main() {
|
||||
armModel = prov.DefaultModel()
|
||||
}
|
||||
armID := router.NewArmID(*providerName, armModel)
|
||||
rtr.RegisterArm(&router.Arm{
|
||||
arm := &router.Arm{
|
||||
ID: armID,
|
||||
Provider: prov,
|
||||
ModelName: armModel,
|
||||
IsLocal: localProviders[*providerName],
|
||||
Capabilities: provider.Capabilities{ToolUse: true}, // trust CLI provider
|
||||
})
|
||||
}
|
||||
arm.Pools = resolveRateLimitPools(armID, *providerName, armModel, cfg)
|
||||
rtr.RegisterArm(arm)
|
||||
rtr.ForceArm(armID)
|
||||
if len(arm.Pools) > 0 {
|
||||
logger.Debug("rate limit pools attached", "arm", armID, "pools", len(arm.Pools))
|
||||
}
|
||||
|
||||
// Discover local models (ollama + llama.cpp) and register as additional arms
|
||||
localModels := router.DiscoverLocalModels(context.Background(), logger,
|
||||
@@ -185,7 +190,7 @@ func main() {
|
||||
Tools: reg,
|
||||
Logger: logger,
|
||||
})
|
||||
elfProgressCh := make(chan string, 1)
|
||||
elfProgressCh := make(chan elf.Progress, 16)
|
||||
agentTool := agent.New(elfMgr)
|
||||
agentTool.SetProgressCh(elfProgressCh)
|
||||
reg.Register(agentTool)
|
||||
@@ -291,12 +296,12 @@ func main() {
|
||||
}
|
||||
} else {
|
||||
// TUI mode: permission prompts via channels
|
||||
permCh := make(chan bool) // TUI → engine: y/n response
|
||||
permReqCh := make(chan string, 1) // engine → TUI: tool name requesting permission
|
||||
permCh := make(chan bool) // TUI → engine: y/n response
|
||||
permReqCh := make(chan tui.PermReqMsg, 1) // engine → TUI: tool requesting permission
|
||||
permChecker.SetPromptFunc(func(ctx context.Context, toolName string, args json.RawMessage) (bool, error) {
|
||||
// Notify TUI that a permission prompt is needed
|
||||
select {
|
||||
case permReqCh <- toolName:
|
||||
case permReqCh <- tui.PermReqMsg{ToolName: toolName, Args: args}:
|
||||
default:
|
||||
}
|
||||
// Block until TUI responds
|
||||
@@ -432,6 +437,45 @@ func buildToolRegistry() *tool.Registry {
|
||||
return reg
|
||||
}
|
||||
|
||||
// resolveRateLimitPools builds limit pools for an arm from provider defaults + config overrides.
|
||||
func resolveRateLimitPools(armID router.ArmID, provName, modelName string, cfg *gnomacfg.Config) []*router.LimitPool {
|
||||
defaults := provider.DefaultRateLimits(provName)
|
||||
rl, _ := defaults.LookupModel(modelName)
|
||||
|
||||
// Apply config overrides
|
||||
if cfg.RateLimits != nil {
|
||||
if override, ok := cfg.RateLimits[provName]; ok {
|
||||
if override.RPS > 0 {
|
||||
rl.RPS = override.RPS
|
||||
}
|
||||
if override.RPM > 0 {
|
||||
rl.RPM = override.RPM
|
||||
}
|
||||
if override.RPD > 0 {
|
||||
rl.RPD = override.RPD
|
||||
}
|
||||
if override.TPM > 0 {
|
||||
rl.TPM = override.TPM
|
||||
}
|
||||
if override.ITPM > 0 {
|
||||
rl.ITPM = override.ITPM
|
||||
}
|
||||
if override.OTPM > 0 {
|
||||
rl.OTPM = override.OTPM
|
||||
}
|
||||
if override.TokensMonth > 0 {
|
||||
rl.TokensMonth = override.TokensMonth
|
||||
}
|
||||
if override.SpendCap > 0 {
|
||||
rl.SpendCap = override.SpendCap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return router.PoolsFromRateLimits(armID, rl)
|
||||
}
|
||||
|
||||
const defaultSystem = `You are gnoma, a provider-agnostic agentic coding assistant.
|
||||
You help users with software engineering tasks by reading files, writing code, and executing commands.
|
||||
Be concise and direct. Use tools when needed to accomplish the task.`
|
||||
Be concise and direct. Use tools when needed to accomplish the task.
|
||||
When spawning multiple elfs (sub-agents), call ALL agent tools in a single response so they run in parallel. Do NOT spawn one elf, wait for its result, then spawn the next.`
|
||||
|
||||
43
go.mod
43
go.mod
@@ -7,22 +7,23 @@ require (
|
||||
charm.land/bubbletea/v2 v2.0.2
|
||||
charm.land/glamour/v2 v2.0.0
|
||||
charm.land/lipgloss/v2 v2.0.2
|
||||
github.com/BurntSushi/toml v0.3.1
|
||||
github.com/BurntSushi/toml v1.6.0
|
||||
github.com/VikingOwl91/mistral-go-sdk v1.3.0
|
||||
github.com/anthropics/anthropic-sdk-go v1.29.0
|
||||
github.com/openai/openai-go v1.12.0
|
||||
golang.org/x/text v0.27.0
|
||||
golang.org/x/text v0.35.0
|
||||
google.golang.org/genai v1.52.1
|
||||
mvdan.cc/sh/v3 v3.13.0
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go v0.116.0 // indirect
|
||||
cloud.google.com/go/auth v0.9.3 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.5.0 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
|
||||
cloud.google.com/go v0.123.0 // indirect
|
||||
cloud.google.com/go/auth v0.19.0 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.23.1 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.2 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
@@ -32,11 +33,14 @@ require (
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/s2a-go v0.1.8 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.18.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
@@ -51,12 +55,17 @@ require (
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yuin/goldmark v1.7.8 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.5 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
golang.org/x/crypto v0.40.0 // indirect
|
||||
golang.org/x/net v0.41.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
|
||||
go.opentelemetry.io/otel v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.42.0 // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
|
||||
google.golang.org/grpc v1.66.2 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
google.golang.org/api v0.267.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
|
||||
google.golang.org/grpc v1.79.3 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
|
||||
201
go.sum
201
go.sum
@@ -6,27 +6,24 @@ charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U=
|
||||
charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w=
|
||||
charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs=
|
||||
charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
|
||||
cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
|
||||
cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U=
|
||||
cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk=
|
||||
cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY=
|
||||
cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY=
|
||||
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
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.19.0 h1:DGYwtbcsGsT1ywuxsIoWi1u/vlks0moIblQHgSDgQkQ=
|
||||
cloud.google.com/go/auth v0.19.0/go.mod h1:2Aph7BT2KnaSFOM0JDPyiYgNh6PL9vGMiP8CUIXZ+IY=
|
||||
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/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/VikingOwl91/mistral-go-sdk v1.2.1 h1:6OQMtOzJUFcvFUEtbX9VlglUPBn+dKOrQPnyoVKlpkA=
|
||||
github.com/VikingOwl91/mistral-go-sdk v1.2.1/go.mod h1:f4emNtHUx2zSqY3V0LBz6lNI1jE6q/zh+SEU+/hJ0i4=
|
||||
github.com/VikingOwl91/mistral-go-sdk v1.3.0 h1:OkTsodDE5lmdf7p2cwScqD2vIk8sScQ2IGk65dUjuz0=
|
||||
github.com/VikingOwl91/mistral-go-sdk v1.3.0/go.mod h1:f4emNtHUx2zSqY3V0LBz6lNI1jE6q/zh+SEU+/hJ0i4=
|
||||
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
|
||||
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
|
||||
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
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/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/anthropics/anthropic-sdk-go v1.29.0 h1:7h1ZyRflhtxyuFkdwkVuJ1LdFAYdmizvgg0gd1uvOfI=
|
||||
github.com/anthropics/anthropic-sdk-go v1.29.0/go.mod h1:dSIO7kSrOI7MA4fE6RRVaw8tyWP7HNQU5/H/KS4cax8=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
@@ -35,7 +32,8 @@ github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
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/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
|
||||
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
|
||||
@@ -52,52 +50,37 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
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/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
|
||||
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
|
||||
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
|
||||
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
|
||||
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.18.0 h1:jxP5Uuo3bxm3M6gGtV94P4lliVetoCB4Wk2x8QA86LI=
|
||||
github.com/googleapis/gax-go/v2 v2.18.0/go.mod h1:uSzZN4a356eRG985CzJ3WfbFSpqkLTjsnhWGJR6EwrE=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
@@ -120,19 +103,12 @@ github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1
|
||||
github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
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=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
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/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=
|
||||
@@ -150,82 +126,51 @@ github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
|
||||
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
|
||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
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/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
|
||||
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
|
||||
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
|
||||
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
|
||||
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/api v0.267.0 h1:w+vfWPMPYeRs8qH1aYYsFX68jMls5acWl/jocfLomwE=
|
||||
google.golang.org/api v0.267.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0=
|
||||
google.golang.org/genai v1.52.1 h1:dYoljKtLDXMiBdVaClSJ/ZPwZ7j1N0lGjMhwOKOQUlk=
|
||||
google.golang.org/genai v1.52.1/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||
google.golang.org/grpc v1.66.2 h1:3QdXkuq3Bkh7w+ywLdLvM56cmGvQHUMZpiCzt6Rqaoo=
|
||||
google.golang.org/grpc v1.66.2/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d h1:vsOm753cOAMkt76efriTCDKjpCbK18XGHMJHo0JUKhc=
|
||||
google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:0oz9d7g9QLSdv9/lgbIjowW1JoxMbxmBVNe8i6tORJI=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d h1:EocjzKLywydp5uZ5tJ79iP6Q0UjDnyiHkGRWxuPBP8s=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:48U2I+QQUYhsFrg2SY6r+nJzeOtjey7j//WBESw+qyQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
mvdan.cc/sh/v3 v3.13.0 h1:dSfq/MVsY4w0Vsi6Lbs0IcQquMVqLdKLESAOZjuHdLg=
|
||||
mvdan.cc/sh/v3 v3.13.0/go.mod h1:KV1GByGPc/Ho0X1E6Uz9euhsIQEj4hwyKnodLlFLoDM=
|
||||
|
||||
@@ -7,6 +7,7 @@ type Config struct {
|
||||
Provider ProviderSection `toml:"provider"`
|
||||
Permission PermissionSection `toml:"permission"`
|
||||
Tools ToolsSection `toml:"tools"`
|
||||
RateLimits RateLimitSection `toml:"rate_limits"`
|
||||
}
|
||||
|
||||
type PermissionSection struct {
|
||||
@@ -34,6 +35,34 @@ type ToolsSection struct {
|
||||
MaxFileSize int64 `toml:"max_file_size"`
|
||||
}
|
||||
|
||||
// RateLimitSection allows overriding default rate limits per provider.
|
||||
//
|
||||
// Example config:
|
||||
//
|
||||
// [rate_limits.mistral]
|
||||
// tier = "starter"
|
||||
// rps = 1
|
||||
// spend_cap = 20.0
|
||||
//
|
||||
// [rate_limits.anthropic]
|
||||
// tier = "tier2"
|
||||
// rpm = 1000
|
||||
// itpm = 450000
|
||||
// otpm = 90000
|
||||
type RateLimitSection map[string]RateLimitOverride
|
||||
|
||||
type RateLimitOverride struct {
|
||||
Tier string `toml:"tier"`
|
||||
RPS float64 `toml:"rps"`
|
||||
RPM int `toml:"rpm"`
|
||||
RPD int `toml:"rpd"`
|
||||
TPM int `toml:"tpm"`
|
||||
ITPM int `toml:"itpm"`
|
||||
OTPM int `toml:"otpm"`
|
||||
TokensMonth int64 `toml:"tokens_month"`
|
||||
SpendCap float64 `toml:"spend_cap"`
|
||||
}
|
||||
|
||||
// Duration wraps time.Duration for TOML string parsing (e.g. "30s", "5m").
|
||||
type Duration time.Duration
|
||||
|
||||
|
||||
15
internal/elf/progress.go
Normal file
15
internal/elf/progress.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package elf
|
||||
|
||||
import "time"
|
||||
|
||||
// Progress carries structured status from an active elf to the TUI.
|
||||
type Progress struct {
|
||||
ElfID string // unique elf identifier
|
||||
Description string // task prompt (truncated)
|
||||
ToolUses int // total tool calls completed
|
||||
Tokens int // tokens consumed (input+output)
|
||||
Activity string // current line: "⚙ [bash] running…", "→ output…", ""
|
||||
Done bool // true when elf completes
|
||||
Error string // non-empty on failure
|
||||
Duration time.Duration // set on Done
|
||||
}
|
||||
128
internal/provider/ratelimits.go
Normal file
128
internal/provider/ratelimits.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package provider
|
||||
|
||||
// RateLimits describes the rate limits for a provider+model pair.
|
||||
// Zero values mean "no limit" or "unknown".
|
||||
type RateLimits struct {
|
||||
RPS float64 // requests per second (Mistral global)
|
||||
RPM int // requests per minute
|
||||
RPD int // requests per day
|
||||
TPM int // tokens per minute (combined input+output)
|
||||
ITPM int // input tokens per minute (Anthropic)
|
||||
OTPM int // output tokens per minute (Anthropic)
|
||||
TokensMonth int64 // tokens per month
|
||||
SpendCap float64 // monthly spend cap in provider currency
|
||||
}
|
||||
|
||||
// ProviderDefaults holds default rate limits keyed by model glob.
|
||||
// The special key "*" matches any model not explicitly listed.
|
||||
type ProviderDefaults struct {
|
||||
Provider string
|
||||
Tier string // "free", "tier1", "tier2", etc.
|
||||
Models map[string]RateLimits
|
||||
}
|
||||
|
||||
// DefaultRateLimits returns conservative defaults for known providers.
|
||||
// These are "starter tier" limits — users should override via config.
|
||||
func DefaultRateLimits(providerName string) ProviderDefaults {
|
||||
switch providerName {
|
||||
case "mistral":
|
||||
return mistralDefaults()
|
||||
case "anthropic":
|
||||
return anthropicDefaults()
|
||||
case "openai":
|
||||
return openaiDefaults()
|
||||
case "google":
|
||||
return googleDefaults()
|
||||
default:
|
||||
return ProviderDefaults{Provider: providerName}
|
||||
}
|
||||
}
|
||||
|
||||
// LookupModel finds rate limits for a specific model, falling back to "*".
|
||||
func (pd ProviderDefaults) LookupModel(model string) (RateLimits, bool) {
|
||||
if rl, ok := pd.Models[model]; ok {
|
||||
return rl, true
|
||||
}
|
||||
if rl, ok := pd.Models["*"]; ok {
|
||||
return rl, true
|
||||
}
|
||||
return RateLimits{}, false
|
||||
}
|
||||
|
||||
func mistralDefaults() ProviderDefaults {
|
||||
// Starter tier from Mistral dashboard. Spend cap is variable — not hardcoded.
|
||||
base := RateLimits{RPS: 1, TPM: 50_000, TokensMonth: 4_000_000}
|
||||
return ProviderDefaults{
|
||||
Provider: "mistral",
|
||||
Tier: "starter",
|
||||
Models: map[string]RateLimits{
|
||||
"*": base,
|
||||
// Magistral models get higher limits
|
||||
"magistral-medium-2509": {RPS: 1, TPM: 75_000, TokensMonth: 1_000_000_000},
|
||||
"magistral-small-2509": {RPS: 1, TPM: 75_000, TokensMonth: 1_000_000_000},
|
||||
// Large/medium get higher TPM
|
||||
"mistral-large-2411": {RPS: 1, TPM: 600_000, TokensMonth: 200_000_000_000},
|
||||
"mistral-large-latest": {RPS: 1, TPM: 50_000, TokensMonth: 4_000_000},
|
||||
"mistral-medium-2505": {RPS: 1, TPM: 375_000},
|
||||
"mistral-medium-2508": {RPS: 1, TPM: 375_000},
|
||||
"mistral-small-2603": {RPS: 1, TPM: 375_000},
|
||||
// Codestral
|
||||
"codestral-2508": {RPS: 1, TPM: 50_000, TokensMonth: 4_000_000},
|
||||
// Pixtral
|
||||
"pixtral-large-2411": {RPS: 1, TPM: 50_000, TokensMonth: 4_000_000},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func anthropicDefaults() ProviderDefaults {
|
||||
// Tier 1 (lowest paid tier, $5 deposit). Users on higher tiers override via config.
|
||||
return ProviderDefaults{
|
||||
Provider: "anthropic",
|
||||
Tier: "tier1",
|
||||
Models: map[string]RateLimits{
|
||||
"*": {RPM: 50, ITPM: 30_000, OTPM: 8_000},
|
||||
// Claude 4.x Opus (shared across 4, 4.1, 4.5, 4.6)
|
||||
"claude-opus-4-20250514": {RPM: 50, ITPM: 30_000, OTPM: 8_000},
|
||||
"claude-opus-4-0": {RPM: 50, ITPM: 30_000, OTPM: 8_000},
|
||||
// Claude 4.x Sonnet (shared across 4, 4.5, 4.6)
|
||||
"claude-sonnet-4-20250514": {RPM: 50, ITPM: 30_000, OTPM: 8_000},
|
||||
"claude-sonnet-4-0": {RPM: 50, ITPM: 30_000, OTPM: 8_000},
|
||||
// Haiku
|
||||
"claude-haiku-4-5-20251001": {RPM: 50, ITPM: 50_000, OTPM: 10_000},
|
||||
"claude-3-5-haiku-20241022": {RPM: 50, ITPM: 50_000, OTPM: 10_000},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func openaiDefaults() ProviderDefaults {
|
||||
// Tier 1 ($5 paid). Higher tiers have dramatically higher limits.
|
||||
return ProviderDefaults{
|
||||
Provider: "openai",
|
||||
Tier: "tier1",
|
||||
Models: map[string]RateLimits{
|
||||
"*": {RPM: 500, TPM: 30_000, RPD: 10_000},
|
||||
"gpt-4o": {RPM: 500, TPM: 30_000, RPD: 10_000},
|
||||
"gpt-4o-mini": {RPM: 500, TPM: 200_000, RPD: 10_000},
|
||||
"o1": {RPM: 500, TPM: 30_000},
|
||||
"o3": {RPM: 500, TPM: 30_000},
|
||||
"o3-mini": {RPM: 500, TPM: 200_000},
|
||||
"o4-mini": {RPM: 500, TPM: 200_000},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func googleDefaults() ProviderDefaults {
|
||||
// Free tier. Pay-as-you-go Tier 1 is significantly higher.
|
||||
return ProviderDefaults{
|
||||
Provider: "google",
|
||||
Tier: "free",
|
||||
Models: map[string]RateLimits{
|
||||
"*": {RPM: 15, TPM: 250_000, RPD: 250},
|
||||
"gemini-2.5-pro": {RPM: 5, TPM: 250_000, RPD: 100},
|
||||
"gemini-2.5-pro-preview-05-06": {RPM: 5, TPM: 250_000, RPD: 100},
|
||||
"gemini-2.5-flash": {RPM: 15, TPM: 250_000, RPD: 250},
|
||||
"gemini-2.5-flash-preview-04-17": {RPM: 15, TPM: 250_000, RPD: 250},
|
||||
"gemini-2.0-flash": {RPM: 10, RPD: 1_500},
|
||||
},
|
||||
}
|
||||
}
|
||||
117
internal/provider/ratelimits_test.go
Normal file
117
internal/provider/ratelimits_test.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package provider
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDefaultRateLimits_Mistral(t *testing.T) {
|
||||
defaults := DefaultRateLimits("mistral")
|
||||
|
||||
if defaults.Provider != "mistral" {
|
||||
t.Errorf("Provider = %q, want mistral", defaults.Provider)
|
||||
}
|
||||
|
||||
// Wildcard should match unknown models
|
||||
rl, ok := defaults.LookupModel("some-unknown-model")
|
||||
if !ok {
|
||||
t.Fatal("wildcard should match unknown models")
|
||||
}
|
||||
if rl.RPS != 1 {
|
||||
t.Errorf("RPS = %v, want 1", rl.RPS)
|
||||
}
|
||||
if rl.TPM != 50_000 {
|
||||
t.Errorf("TPM = %d, want 50000", rl.TPM)
|
||||
}
|
||||
if rl.TokensMonth != 4_000_000 {
|
||||
t.Errorf("TokensMonth = %d, want 4000000", rl.TokensMonth)
|
||||
}
|
||||
|
||||
// Specific model
|
||||
rl, ok = defaults.LookupModel("magistral-medium-2509")
|
||||
if !ok {
|
||||
t.Fatal("magistral-medium-2509 should be found")
|
||||
}
|
||||
if rl.TPM != 75_000 {
|
||||
t.Errorf("magistral TPM = %d, want 75000", rl.TPM)
|
||||
}
|
||||
if rl.TokensMonth != 1_000_000_000 {
|
||||
t.Errorf("magistral TokensMonth = %d, want 1000000000", rl.TokensMonth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultRateLimits_Anthropic(t *testing.T) {
|
||||
defaults := DefaultRateLimits("anthropic")
|
||||
|
||||
rl, ok := defaults.LookupModel("claude-sonnet-4-20250514")
|
||||
if !ok {
|
||||
t.Fatal("claude-sonnet should be found")
|
||||
}
|
||||
if rl.RPM != 50 {
|
||||
t.Errorf("RPM = %d, want 50", rl.RPM)
|
||||
}
|
||||
if rl.ITPM != 30_000 {
|
||||
t.Errorf("ITPM = %d, want 30000", rl.ITPM)
|
||||
}
|
||||
if rl.OTPM != 8_000 {
|
||||
t.Errorf("OTPM = %d, want 8000", rl.OTPM)
|
||||
}
|
||||
|
||||
// Haiku has different limits
|
||||
rl, _ = defaults.LookupModel("claude-haiku-4-5-20251001")
|
||||
if rl.ITPM != 50_000 {
|
||||
t.Errorf("Haiku ITPM = %d, want 50000", rl.ITPM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultRateLimits_OpenAI(t *testing.T) {
|
||||
defaults := DefaultRateLimits("openai")
|
||||
|
||||
rl, ok := defaults.LookupModel("gpt-4o")
|
||||
if !ok {
|
||||
t.Fatal("gpt-4o should be found")
|
||||
}
|
||||
if rl.RPM != 500 {
|
||||
t.Errorf("RPM = %d, want 500", rl.RPM)
|
||||
}
|
||||
if rl.TPM != 30_000 {
|
||||
t.Errorf("TPM = %d, want 30000", rl.TPM)
|
||||
}
|
||||
if rl.RPD != 10_000 {
|
||||
t.Errorf("RPD = %d, want 10000", rl.RPD)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultRateLimits_Google(t *testing.T) {
|
||||
defaults := DefaultRateLimits("google")
|
||||
|
||||
rl, ok := defaults.LookupModel("gemini-2.5-pro")
|
||||
if !ok {
|
||||
t.Fatal("gemini-2.5-pro should be found")
|
||||
}
|
||||
if rl.RPM != 5 {
|
||||
t.Errorf("RPM = %d, want 5", rl.RPM)
|
||||
}
|
||||
if rl.RPD != 100 {
|
||||
t.Errorf("RPD = %d, want 100", rl.RPD)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultRateLimits_Unknown(t *testing.T) {
|
||||
defaults := DefaultRateLimits("unknown-provider")
|
||||
|
||||
_, ok := defaults.LookupModel("any-model")
|
||||
if ok {
|
||||
t.Error("unknown provider should return no limits")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupModel_FallbackToWildcard(t *testing.T) {
|
||||
defaults := DefaultRateLimits("mistral")
|
||||
|
||||
// Non-existent model falls back to wildcard
|
||||
rl, ok := defaults.LookupModel("mistral-future-2099")
|
||||
if !ok {
|
||||
t.Fatal("should fall back to wildcard")
|
||||
}
|
||||
if rl.TPM != 50_000 {
|
||||
t.Errorf("wildcard TPM = %d, want 50000", rl.TPM)
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,14 @@ import (
|
||||
type PoolKind int
|
||||
|
||||
const (
|
||||
PoolRPM PoolKind = iota // requests per minute
|
||||
PoolRPD // requests per day
|
||||
PoolTPD // tokens per day
|
||||
PoolCostEUR // monetary cost cap
|
||||
PoolCustom // arbitrary units
|
||||
PoolRPM PoolKind = iota // requests per minute
|
||||
PoolRPS // requests per second
|
||||
PoolRPD // requests per day
|
||||
PoolTPM // tokens per minute
|
||||
PoolTPD // tokens per day
|
||||
PoolTokensMonth // tokens per month
|
||||
PoolCostMonth // monetary cost cap per month
|
||||
PoolCustom // arbitrary units
|
||||
)
|
||||
|
||||
// LimitPool tracks a shared resource budget that arms draw from.
|
||||
|
||||
127
internal/router/pools.go
Normal file
127
internal/router/pools.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"somegit.dev/Owlibou/gnoma/internal/provider"
|
||||
)
|
||||
|
||||
// PoolsFromRateLimits creates limit pools for an arm based on rate limits.
|
||||
// Each non-zero limit dimension becomes a pool attached to the arm.
|
||||
func PoolsFromRateLimits(armID ArmID, rl provider.RateLimits) []*LimitPool {
|
||||
now := time.Now()
|
||||
var pools []*LimitPool
|
||||
|
||||
if rl.RPS > 0 {
|
||||
pools = append(pools, &LimitPool{
|
||||
ID: fmt.Sprintf("%s/rps", armID),
|
||||
Kind: PoolRPS,
|
||||
TotalLimit: rl.RPS,
|
||||
ResetPeriod: time.Second,
|
||||
ResetAt: now.Add(time.Second),
|
||||
ArmRates: map[ArmID]float64{armID: 1.0 / 1000.0}, // 1 request per call, normalized
|
||||
ScarcityK: 3.0,
|
||||
})
|
||||
}
|
||||
|
||||
if rl.RPM > 0 {
|
||||
pools = append(pools, &LimitPool{
|
||||
ID: fmt.Sprintf("%s/rpm", armID),
|
||||
Kind: PoolRPM,
|
||||
TotalLimit: float64(rl.RPM),
|
||||
ResetPeriod: time.Minute,
|
||||
ResetAt: now.Add(time.Minute),
|
||||
ArmRates: map[ArmID]float64{armID: 1.0 / 1000.0},
|
||||
ScarcityK: 2.0,
|
||||
})
|
||||
}
|
||||
|
||||
if rl.RPD > 0 {
|
||||
pools = append(pools, &LimitPool{
|
||||
ID: fmt.Sprintf("%s/rpd", armID),
|
||||
Kind: PoolRPD,
|
||||
TotalLimit: float64(rl.RPD),
|
||||
ResetPeriod: 24 * time.Hour,
|
||||
ResetAt: nextMidnight(),
|
||||
ArmRates: map[ArmID]float64{armID: 1.0 / 1000.0},
|
||||
ScarcityK: 2.0,
|
||||
})
|
||||
}
|
||||
|
||||
if rl.TPM > 0 {
|
||||
pools = append(pools, &LimitPool{
|
||||
ID: fmt.Sprintf("%s/tpm", armID),
|
||||
Kind: PoolTPM,
|
||||
TotalLimit: float64(rl.TPM),
|
||||
ResetPeriod: time.Minute,
|
||||
ResetAt: now.Add(time.Minute),
|
||||
ArmRates: map[ArmID]float64{armID: 1.0}, // 1 token per token
|
||||
ScarcityK: 2.0,
|
||||
})
|
||||
}
|
||||
|
||||
// Anthropic-style split: ITPM+OTPM. Use TPM pool kind for the more
|
||||
// restrictive one (output) since that's what throttles agentic use.
|
||||
if rl.ITPM > 0 && rl.TPM == 0 {
|
||||
pools = append(pools, &LimitPool{
|
||||
ID: fmt.Sprintf("%s/itpm", armID),
|
||||
Kind: PoolTPM,
|
||||
TotalLimit: float64(rl.ITPM),
|
||||
ResetPeriod: time.Minute,
|
||||
ResetAt: now.Add(time.Minute),
|
||||
ArmRates: map[ArmID]float64{armID: 0.6}, // ~60% of tokens are input
|
||||
ScarcityK: 2.0,
|
||||
})
|
||||
}
|
||||
if rl.OTPM > 0 {
|
||||
pools = append(pools, &LimitPool{
|
||||
ID: fmt.Sprintf("%s/otpm", armID),
|
||||
Kind: PoolTPM,
|
||||
TotalLimit: float64(rl.OTPM),
|
||||
ResetPeriod: time.Minute,
|
||||
ResetAt: now.Add(time.Minute),
|
||||
ArmRates: map[ArmID]float64{armID: 0.4}, // ~40% of tokens are output
|
||||
ScarcityK: 3.0, // output is more precious
|
||||
})
|
||||
}
|
||||
|
||||
if rl.TokensMonth > 0 {
|
||||
pools = append(pools, &LimitPool{
|
||||
ID: fmt.Sprintf("%s/tokens-month", armID),
|
||||
Kind: PoolTokensMonth,
|
||||
TotalLimit: float64(rl.TokensMonth),
|
||||
ResetPeriod: 30 * 24 * time.Hour,
|
||||
ResetAt: nextMonthStart(),
|
||||
ArmRates: map[ArmID]float64{armID: 1.0},
|
||||
ScarcityK: 4.0, // aggressive hoarding for monthly budgets
|
||||
})
|
||||
}
|
||||
|
||||
if rl.SpendCap > 0 {
|
||||
pools = append(pools, &LimitPool{
|
||||
ID: fmt.Sprintf("%s/spend-cap", armID),
|
||||
Kind: PoolCostMonth,
|
||||
TotalLimit: rl.SpendCap,
|
||||
ResetPeriod: 30 * 24 * time.Hour,
|
||||
ResetAt: nextMonthStart(),
|
||||
ArmRates: map[ArmID]float64{}, // cost tracked externally per arm
|
||||
ScarcityK: 4.0,
|
||||
})
|
||||
}
|
||||
|
||||
return pools
|
||||
}
|
||||
|
||||
func nextMidnight() time.Time {
|
||||
now := time.Now()
|
||||
return time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
|
||||
}
|
||||
|
||||
func nextMonthStart() time.Time {
|
||||
now := time.Now()
|
||||
if now.Month() == 12 {
|
||||
return time.Date(now.Year()+1, 1, 1, 0, 0, 0, 0, now.Location())
|
||||
}
|
||||
return time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, now.Location())
|
||||
}
|
||||
120
internal/router/pools_test.go
Normal file
120
internal/router/pools_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"somegit.dev/Owlibou/gnoma/internal/provider"
|
||||
)
|
||||
|
||||
func TestPoolsFromRateLimits_Mistral(t *testing.T) {
|
||||
armID := ArmID("mistral/mistral-large-latest")
|
||||
rl := provider.RateLimits{RPS: 1, TPM: 50_000, TokensMonth: 4_000_000}
|
||||
|
||||
pools := PoolsFromRateLimits(armID, rl)
|
||||
|
||||
// Should create: RPS, TPM, TokensMonth = 3 pools
|
||||
if len(pools) != 3 {
|
||||
t.Fatalf("len(pools) = %d, want 3", len(pools))
|
||||
}
|
||||
|
||||
kinds := map[PoolKind]bool{}
|
||||
for _, p := range pools {
|
||||
kinds[p.Kind] = true
|
||||
}
|
||||
if !kinds[PoolRPS] {
|
||||
t.Error("missing RPS pool")
|
||||
}
|
||||
if !kinds[PoolTPM] {
|
||||
t.Error("missing TPM pool")
|
||||
}
|
||||
if !kinds[PoolTokensMonth] {
|
||||
t.Error("missing TokensMonth pool")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolsFromRateLimits_Anthropic(t *testing.T) {
|
||||
armID := ArmID("anthropic/claude-sonnet-4-20250514")
|
||||
rl := provider.RateLimits{RPM: 50, ITPM: 30_000, OTPM: 8_000}
|
||||
|
||||
pools := PoolsFromRateLimits(armID, rl)
|
||||
|
||||
// Should create: RPM, ITPM (as TPM), OTPM (as TPM) = 3 pools
|
||||
if len(pools) != 3 {
|
||||
t.Fatalf("len(pools) = %d, want 3", len(pools))
|
||||
}
|
||||
|
||||
var rpmPool *LimitPool
|
||||
for _, p := range pools {
|
||||
if p.Kind == PoolRPM {
|
||||
rpmPool = p
|
||||
}
|
||||
}
|
||||
if rpmPool == nil {
|
||||
t.Fatal("missing RPM pool")
|
||||
}
|
||||
if rpmPool.TotalLimit != 50 {
|
||||
t.Errorf("RPM limit = %f, want 50", rpmPool.TotalLimit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolsFromRateLimits_OpenAI(t *testing.T) {
|
||||
armID := ArmID("openai/gpt-4o")
|
||||
rl := provider.RateLimits{RPM: 500, TPM: 30_000, RPD: 10_000}
|
||||
|
||||
pools := PoolsFromRateLimits(armID, rl)
|
||||
|
||||
// Should create: RPM, RPD, TPM = 3 pools
|
||||
if len(pools) != 3 {
|
||||
t.Fatalf("len(pools) = %d, want 3", len(pools))
|
||||
}
|
||||
|
||||
kinds := map[PoolKind]int{}
|
||||
for _, p := range pools {
|
||||
kinds[p.Kind]++
|
||||
}
|
||||
if kinds[PoolRPM] != 1 {
|
||||
t.Error("expected 1 RPM pool")
|
||||
}
|
||||
if kinds[PoolRPD] != 1 {
|
||||
t.Error("expected 1 RPD pool")
|
||||
}
|
||||
if kinds[PoolTPM] != 1 {
|
||||
t.Error("expected 1 TPM pool")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolsFromRateLimits_Empty(t *testing.T) {
|
||||
pools := PoolsFromRateLimits("test/model", provider.RateLimits{})
|
||||
if len(pools) != 0 {
|
||||
t.Errorf("empty rate limits should produce 0 pools, got %d", len(pools))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolsFromRateLimits_SpendCap(t *testing.T) {
|
||||
armID := ArmID("mistral/model")
|
||||
rl := provider.RateLimits{SpendCap: 20.0}
|
||||
|
||||
pools := PoolsFromRateLimits(armID, rl)
|
||||
if len(pools) != 1 {
|
||||
t.Fatalf("len(pools) = %d, want 1", len(pools))
|
||||
}
|
||||
if pools[0].Kind != PoolCostMonth {
|
||||
t.Errorf("Kind = %v, want PoolCostMonth", pools[0].Kind)
|
||||
}
|
||||
if pools[0].TotalLimit != 20.0 {
|
||||
t.Errorf("TotalLimit = %f, want 20.0", pools[0].TotalLimit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolsFromRateLimits_RPSResetPeriod(t *testing.T) {
|
||||
armID := ArmID("mistral/model")
|
||||
rl := provider.RateLimits{RPS: 1}
|
||||
|
||||
pools := PoolsFromRateLimits(armID, rl)
|
||||
if len(pools) != 1 {
|
||||
t.Fatalf("len(pools) = %d, want 1", len(pools))
|
||||
}
|
||||
if pools[0].ResetPeriod.Seconds() != 1.0 {
|
||||
t.Errorf("RPS reset period = %v, want 1s", pools[0].ResetPeriod)
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,13 @@ func (s *Local) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetModel updates the displayed model name.
|
||||
func (s *Local) SetModel(model string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.model = model
|
||||
}
|
||||
|
||||
func (s *Local) Status() Status {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -25,10 +25,6 @@ var paramSchema = json.RawMessage(`{
|
||||
"description": "Task type hint for provider routing",
|
||||
"enum": ["generation", "review", "refactor", "debug", "explain", "planning"]
|
||||
},
|
||||
"wait": {
|
||||
"type": "boolean",
|
||||
"description": "Wait for the elf to complete (default true)"
|
||||
},
|
||||
"max_turns": {
|
||||
"type": "integer",
|
||||
"description": "Maximum tool-calling rounds for the elf (default 30)"
|
||||
@@ -40,7 +36,7 @@ var paramSchema = json.RawMessage(`{
|
||||
// Tool allows the LLM to spawn sub-agents (elfs).
|
||||
type Tool struct {
|
||||
manager *elf.Manager
|
||||
ProgressCh chan<- string // optional: sends 2-line progress to TUI
|
||||
ProgressCh chan<- elf.Progress // optional: sends structured progress to TUI
|
||||
}
|
||||
|
||||
func New(mgr *elf.Manager) *Tool {
|
||||
@@ -48,12 +44,12 @@ func New(mgr *elf.Manager) *Tool {
|
||||
}
|
||||
|
||||
// SetProgressCh sets the channel for forwarding elf progress to the TUI.
|
||||
func (t *Tool) SetProgressCh(ch chan<- string) {
|
||||
func (t *Tool) SetProgressCh(ch chan<- elf.Progress) {
|
||||
t.ProgressCh = ch
|
||||
}
|
||||
|
||||
func (t *Tool) Name() string { return "agent" }
|
||||
func (t *Tool) Description() string { return "Spawn a sub-agent (elf) to handle a task independently. The elf gets its own conversation and tools." }
|
||||
func (t *Tool) Description() string { return "Spawn a sub-agent (elf) to handle a task independently. The elf gets its own conversation and tools. IMPORTANT: To spawn multiple elfs in parallel, call this tool multiple times in the SAME response — do not wait for one to finish before spawning the next." }
|
||||
func (t *Tool) Parameters() json.RawMessage { return paramSchema }
|
||||
func (t *Tool) IsReadOnly() bool { return true }
|
||||
func (t *Tool) IsDestructive() bool { return false }
|
||||
@@ -61,7 +57,6 @@ func (t *Tool) IsDestructive() bool { return false }
|
||||
type agentArgs struct {
|
||||
Prompt string `json:"prompt"`
|
||||
TaskType string `json:"task_type,omitempty"`
|
||||
Wait *bool `json:"wait,omitempty"`
|
||||
MaxTurns int `json:"max_turns,omitempty"`
|
||||
}
|
||||
|
||||
@@ -75,15 +70,17 @@ func (t *Tool) Execute(ctx context.Context, args json.RawMessage) (tool.Result,
|
||||
}
|
||||
|
||||
taskType := parseTaskType(a.TaskType)
|
||||
wait := true
|
||||
if a.Wait != nil {
|
||||
wait = *a.Wait
|
||||
}
|
||||
maxTurns := a.MaxTurns
|
||||
if maxTurns <= 0 {
|
||||
maxTurns = 30 // default
|
||||
}
|
||||
|
||||
// Truncate description for tree display
|
||||
desc := a.Prompt
|
||||
if len(desc) > 60 {
|
||||
desc = desc[:60] + "…"
|
||||
}
|
||||
|
||||
systemPrompt := "You are an elf — a focused sub-agent of gnoma. Complete the given task thoroughly and concisely. Use tools as needed."
|
||||
|
||||
e, err := t.manager.Spawn(ctx, taskType, a.Prompt, systemPrompt, maxTurns)
|
||||
@@ -91,66 +88,71 @@ func (t *Tool) Execute(ctx context.Context, args json.RawMessage) (tool.Result,
|
||||
return tool.Result{Output: fmt.Sprintf("Failed to spawn elf: %v", err)}, nil
|
||||
}
|
||||
|
||||
if !wait {
|
||||
return tool.Result{
|
||||
Output: fmt.Sprintf("Elf %s spawned in background (task: %s)", e.ID(), taskType),
|
||||
Metadata: map[string]any{"elf_id": e.ID(), "background": true},
|
||||
}, nil
|
||||
}
|
||||
// Send initial progress
|
||||
t.sendProgress(elf.Progress{
|
||||
ElfID: e.ID(),
|
||||
Description: desc,
|
||||
Activity: "starting…",
|
||||
})
|
||||
|
||||
// Drain elf events while waiting, forward progress to TUI
|
||||
done := make(chan elf.Result, 1)
|
||||
go func() { done <- e.Wait() }()
|
||||
|
||||
// Forward elf streaming events as live progress
|
||||
// Forward elf streaming events as structured progress
|
||||
go func() {
|
||||
var textBuf strings.Builder
|
||||
toolUses := 0
|
||||
tokens := 0
|
||||
lastSend := time.Now()
|
||||
textChars := 0
|
||||
|
||||
for evt := range e.Events() {
|
||||
if t.ProgressCh == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var progress string
|
||||
p := elf.Progress{
|
||||
ElfID: e.ID(),
|
||||
Description: desc,
|
||||
ToolUses: toolUses,
|
||||
Tokens: tokens,
|
||||
}
|
||||
|
||||
switch evt.Type {
|
||||
case stream.EventTextDelta:
|
||||
if evt.Text != "" {
|
||||
textBuf.WriteString(evt.Text)
|
||||
// Show last 2 non-empty lines of text
|
||||
allLines := strings.Split(textBuf.String(), "\n")
|
||||
var recent []string
|
||||
for i := len(allLines) - 1; i >= 0 && len(recent) < 2; i-- {
|
||||
line := strings.TrimSpace(allLines[i])
|
||||
if line != "" {
|
||||
if len(line) > 70 {
|
||||
line = line[:70] + "…"
|
||||
}
|
||||
recent = append([]string{line}, recent...)
|
||||
}
|
||||
}
|
||||
progress = strings.Join(recent, "\n")
|
||||
textChars += len(evt.Text)
|
||||
// Throttle text progress to every 500ms
|
||||
if time.Since(lastSend) < 500*time.Millisecond {
|
||||
continue
|
||||
}
|
||||
p.Activity = fmt.Sprintf("generating… (%d chars)", textChars)
|
||||
case stream.EventToolCallDone:
|
||||
name := evt.ToolCallName
|
||||
if name == "" {
|
||||
name = "tool"
|
||||
}
|
||||
progress = fmt.Sprintf("⚙ [%s] running...", name)
|
||||
p.Activity = fmt.Sprintf("⚙ [%s] running…", name)
|
||||
case stream.EventToolResult:
|
||||
// Show truncated tool result
|
||||
toolUses++
|
||||
p.ToolUses = toolUses
|
||||
out := evt.ToolOutput
|
||||
if len(out) > 70 {
|
||||
out = out[:70] + "…"
|
||||
if len(out) > 60 {
|
||||
out = out[:60] + "…"
|
||||
}
|
||||
out = strings.ReplaceAll(out, "\n", " ")
|
||||
progress = fmt.Sprintf(" → %s", out)
|
||||
p.Activity = fmt.Sprintf("→ %s", out)
|
||||
case stream.EventUsage:
|
||||
if evt.Usage != nil {
|
||||
tokens = int(evt.Usage.TotalTokens())
|
||||
p.Tokens = tokens
|
||||
}
|
||||
p.Activity = "" // no activity change on usage alone
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
if progress != "" {
|
||||
select {
|
||||
case t.ProgressCh <- progress:
|
||||
default:
|
||||
}
|
||||
}
|
||||
lastSend = time.Now()
|
||||
t.sendProgress(p)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -159,27 +161,45 @@ func (t *Tool) Execute(ctx context.Context, args json.RawMessage) (tool.Result,
|
||||
case result = <-done:
|
||||
case <-ctx.Done():
|
||||
e.Cancel()
|
||||
t.sendProgress(elf.Progress{ElfID: e.ID(), Description: desc, Done: true, Error: "cancelled"})
|
||||
return tool.Result{Output: "Elf cancelled"}, nil
|
||||
case <-time.After(5 * time.Minute):
|
||||
e.Cancel()
|
||||
t.sendProgress(elf.Progress{ElfID: e.ID(), Description: desc, Done: true, Error: "timed out"})
|
||||
return tool.Result{Output: "Elf timed out after 5 minutes"}, nil
|
||||
}
|
||||
|
||||
// Clear progress
|
||||
if t.ProgressCh != nil {
|
||||
select {
|
||||
case t.ProgressCh <- "":
|
||||
default:
|
||||
}
|
||||
// Send done signal — stays in tree until turn completes
|
||||
doneProgress := elf.Progress{
|
||||
ElfID: result.ID,
|
||||
Description: desc,
|
||||
Tokens: int(result.Usage.TotalTokens()),
|
||||
Done: true,
|
||||
Duration: result.Duration,
|
||||
}
|
||||
if result.Error != nil {
|
||||
doneProgress.Error = result.Error.Error()
|
||||
}
|
||||
t.sendProgress(doneProgress)
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "Elf %s completed (%s, %s)\n\n", result.ID, result.Status, result.Duration.Round(time.Millisecond))
|
||||
fmt.Fprintf(&b, "Elf %s completed (%s, %s, %s)\n\n",
|
||||
result.ID, result.Status,
|
||||
result.Duration.Round(time.Millisecond),
|
||||
formatTokens(int(result.Usage.TotalTokens())),
|
||||
)
|
||||
if result.Error != nil {
|
||||
fmt.Fprintf(&b, "Error: %v\n", result.Error)
|
||||
}
|
||||
if result.Output != "" {
|
||||
b.WriteString(result.Output)
|
||||
// Truncate elf output to avoid flooding parent context.
|
||||
// The parent LLM gets enough to summarize; full text stays in the elf.
|
||||
output := result.Output
|
||||
const maxOutputChars = 2000
|
||||
if len(output) > maxOutputChars {
|
||||
output = output[:maxOutputChars] + fmt.Sprintf("\n\n[truncated — full output was %d chars]", len(result.Output))
|
||||
}
|
||||
b.WriteString(output)
|
||||
}
|
||||
|
||||
return tool.Result{
|
||||
@@ -192,6 +212,26 @@ func (t *Tool) Execute(ctx context.Context, args json.RawMessage) (tool.Result,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *Tool) sendProgress(p elf.Progress) {
|
||||
if t.ProgressCh == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case t.ProgressCh <- p:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func formatTokens(tokens int) string {
|
||||
if tokens >= 1_000_000 {
|
||||
return fmt.Sprintf("%.1fM tokens", float64(tokens)/1_000_000)
|
||||
}
|
||||
if tokens >= 1_000 {
|
||||
return fmt.Sprintf("%.1fk tokens", float64(tokens)/1_000)
|
||||
}
|
||||
return fmt.Sprintf("%d tokens", tokens)
|
||||
}
|
||||
|
||||
func parseTaskType(s string) router.TaskType {
|
||||
switch strings.ToLower(s) {
|
||||
case "generation":
|
||||
|
||||
@@ -7,12 +7,14 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/bubbles/v2/textarea"
|
||||
"charm.land/glamour/v2"
|
||||
"charm.land/bubbles/v2/key"
|
||||
"charm.land/lipgloss/v2"
|
||||
"somegit.dev/Owlibou/gnoma/internal/elf"
|
||||
"somegit.dev/Owlibou/gnoma/internal/engine"
|
||||
"somegit.dev/Owlibou/gnoma/internal/message"
|
||||
"somegit.dev/Owlibou/gnoma/internal/permission"
|
||||
@@ -26,8 +28,12 @@ const version = "v0.1.0-dev"
|
||||
|
||||
type streamEventMsg struct{ event stream.Event }
|
||||
type turnDoneMsg struct{ err error }
|
||||
type permReqMsg struct{ toolName string }
|
||||
type elfProgressMsg struct{ text string }
|
||||
// PermReqMsg carries a permission request from engine to TUI.
|
||||
type PermReqMsg struct {
|
||||
ToolName string
|
||||
Args json.RawMessage
|
||||
}
|
||||
type elfProgressMsg struct{ progress elf.Progress }
|
||||
|
||||
type chatMessage struct {
|
||||
role string
|
||||
@@ -41,8 +47,8 @@ type Config struct {
|
||||
Permissions *permission.Checker // for mode switching
|
||||
Router *router.Router // for model listing
|
||||
PermCh chan bool // TUI → engine: y/n response
|
||||
PermReqCh <-chan string // engine → TUI: tool name needing approval
|
||||
ElfProgress <-chan string // elf → TUI: progress updates
|
||||
PermReqCh <-chan PermReqMsg // engine → TUI: tool requesting approval
|
||||
ElfProgress <-chan elf.Progress // elf → TUI: structured progress updates
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
@@ -59,13 +65,16 @@ type Model struct {
|
||||
input textarea.Model
|
||||
mdRenderer *glamour.TermRenderer
|
||||
expandOutput bool // ctrl+o toggles expanded tool output
|
||||
elfProgress string // last 2 lines from active elf
|
||||
elfStates map[string]*elf.Progress // active elf states keyed by ID
|
||||
elfOrder []string // insertion-ordered elf IDs for tree rendering
|
||||
elfToolActive bool // suppresses next toolresult (elf output)
|
||||
cwd string
|
||||
gitBranch string
|
||||
scrollOffset int
|
||||
incognito bool
|
||||
permPending bool // waiting for user to approve/deny a tool
|
||||
permToolName string // which tool is asking
|
||||
permPending bool // waiting for user to approve/deny a tool
|
||||
permToolName string // which tool is asking
|
||||
permArgs json.RawMessage // tool args for display
|
||||
}
|
||||
|
||||
func New(sess session.Session, cfg Config) Model {
|
||||
@@ -106,6 +115,7 @@ func New(sess session.Session, cfg Config) Model {
|
||||
config: cfg,
|
||||
input: ti,
|
||||
mdRenderer: mdRenderer,
|
||||
elfStates: make(map[string]*elf.Progress),
|
||||
cwd: cwd,
|
||||
gitBranch: gitBranch,
|
||||
streamBuf: &strings.Builder{},
|
||||
@@ -240,14 +250,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
|
||||
case elfProgressMsg:
|
||||
m.elfProgress = msg.text
|
||||
p := msg.progress
|
||||
// Keep completed elfs in tree — only cleared on turnDoneMsg
|
||||
if _, exists := m.elfStates[p.ElfID]; !exists {
|
||||
m.elfOrder = append(m.elfOrder, p.ElfID)
|
||||
}
|
||||
m.elfStates[p.ElfID] = &p
|
||||
return m, m.listenForEvents()
|
||||
|
||||
case permReqMsg:
|
||||
case PermReqMsg:
|
||||
m.permPending = true
|
||||
m.permToolName = msg.toolName
|
||||
m.permToolName = msg.ToolName
|
||||
m.permArgs = msg.Args
|
||||
m.messages = append(m.messages, chatMessage{role: "system",
|
||||
content: fmt.Sprintf("⚠ %s wants to execute. Allow? [y/n]", msg.toolName)})
|
||||
content: formatPermissionPrompt(msg.ToolName, msg.Args)})
|
||||
m.scrollOffset = 0
|
||||
return m, nil
|
||||
|
||||
@@ -257,7 +273,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case turnDoneMsg:
|
||||
m.streaming = false
|
||||
m.scrollOffset = 0
|
||||
m.elfProgress = "" // clear elf progress
|
||||
m.elfStates = make(map[string]*elf.Progress) // clear elf states
|
||||
m.elfOrder = nil
|
||||
if m.streamBuf.Len() > 0 {
|
||||
m.messages = append(m.messages, chatMessage{
|
||||
role: m.currentRole, content: m.streamBuf.String(),
|
||||
@@ -383,6 +400,10 @@ func (m Model) handleCommand(cmd string) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
if m.config.Engine != nil {
|
||||
m.config.Engine.SetModel(args)
|
||||
// Update session status display
|
||||
if ls, ok := m.session.(*session.Local); ok {
|
||||
ls.SetModel(args)
|
||||
}
|
||||
m.messages = append(m.messages, chatMessage{role: "system",
|
||||
content: fmt.Sprintf("model switched to: %s", args)})
|
||||
}
|
||||
@@ -478,29 +499,22 @@ func (m Model) handleStreamEvent(evt stream.Event) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
case stream.EventToolCallDone:
|
||||
if evt.ToolCallName == "agent" {
|
||||
// Extract prompt from args for elf display
|
||||
prompt := "working..."
|
||||
if evt.Args != nil {
|
||||
var a struct{ Prompt string }
|
||||
if json.Unmarshal(evt.Args, &a) == nil && a.Prompt != "" {
|
||||
prompt = a.Prompt
|
||||
if len(prompt) > 60 {
|
||||
prompt = prompt[:60] + "..."
|
||||
}
|
||||
}
|
||||
}
|
||||
m.messages = append(m.messages, chatMessage{
|
||||
role: "tool", content: fmt.Sprintf("🦉 [elf] %s", prompt),
|
||||
})
|
||||
// Suppress tool message — elf tree view handles display
|
||||
m.elfToolActive = true
|
||||
} else {
|
||||
m.messages = append(m.messages, chatMessage{
|
||||
role: "tool", content: fmt.Sprintf("⚙ [%s] running...", evt.ToolCallName),
|
||||
})
|
||||
}
|
||||
case stream.EventToolResult:
|
||||
m.messages = append(m.messages, chatMessage{
|
||||
role: "toolresult", content: evt.ToolOutput,
|
||||
})
|
||||
if m.elfToolActive {
|
||||
// Suppress raw elf output — tree shows progress, LLM summarizes
|
||||
m.elfToolActive = false
|
||||
} else {
|
||||
m.messages = append(m.messages, chatMessage{
|
||||
role: "toolresult", content: evt.ToolOutput,
|
||||
})
|
||||
}
|
||||
}
|
||||
return m, m.listenForEvents()
|
||||
}
|
||||
@@ -522,14 +536,14 @@ func (m Model) listenForEvents() tea.Cmd {
|
||||
return turnDoneMsg{err: err}
|
||||
}
|
||||
return streamEventMsg{event: evt}
|
||||
case toolName, ok := <-permReqCh:
|
||||
case req, ok := <-permReqCh:
|
||||
if ok {
|
||||
return permReqMsg{toolName: toolName}
|
||||
return req
|
||||
}
|
||||
return nil
|
||||
case progress, ok := <-elfProgressCh:
|
||||
if ok {
|
||||
return elfProgressMsg{text: progress}
|
||||
return elfProgressMsg{progress: progress}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -617,6 +631,11 @@ func (m Model) renderChat(height int) string {
|
||||
lines = append(lines, m.renderMessage(msg)...)
|
||||
}
|
||||
|
||||
// Elf tree view — shows active elfs with structured progress
|
||||
if m.streaming && len(m.elfStates) > 0 {
|
||||
lines = append(lines, m.renderElfTree()...)
|
||||
}
|
||||
|
||||
// Streaming
|
||||
if m.streaming && m.streamBuf.Len() > 0 {
|
||||
// Stream raw text — markdown rendered only after completion
|
||||
@@ -725,12 +744,6 @@ func (m Model) renderMessage(msg chatMessage) []string {
|
||||
|
||||
case "tool":
|
||||
lines = append(lines, indent+sToolOutput.Render(msg.content))
|
||||
// Show elf progress under elf tool messages
|
||||
if strings.HasPrefix(msg.content, "🦉") && m.streaming && m.elfProgress != "" {
|
||||
for _, pLine := range strings.Split(m.elfProgress, "\n") {
|
||||
lines = append(lines, indent+indent+sToolResult.Render(pLine))
|
||||
}
|
||||
}
|
||||
|
||||
case "toolresult":
|
||||
resultLines := strings.Split(msg.content, "\n")
|
||||
@@ -775,6 +788,102 @@ func (m Model) renderMessage(msg chatMessage) []string {
|
||||
return lines
|
||||
}
|
||||
|
||||
func (m Model) renderElfTree() []string {
|
||||
if len(m.elfOrder) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var lines []string
|
||||
|
||||
// Count running vs done
|
||||
running := 0
|
||||
for _, id := range m.elfOrder {
|
||||
if p, ok := m.elfStates[id]; ok && !p.Done {
|
||||
running++
|
||||
}
|
||||
}
|
||||
|
||||
// Header
|
||||
if running > 0 {
|
||||
header := fmt.Sprintf("● Running %d elf", len(m.elfOrder))
|
||||
if len(m.elfOrder) != 1 {
|
||||
header += "s"
|
||||
}
|
||||
header += "…"
|
||||
lines = append(lines, sStatusStreaming.Render(header))
|
||||
} else {
|
||||
header := fmt.Sprintf("● %d elf", len(m.elfOrder))
|
||||
if len(m.elfOrder) != 1 {
|
||||
header += "s"
|
||||
}
|
||||
header += " completed"
|
||||
lines = append(lines, sToolOutput.Render(header))
|
||||
}
|
||||
|
||||
for i, elfID := range m.elfOrder {
|
||||
p, ok := m.elfStates[elfID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
isLast := i == len(m.elfOrder)-1
|
||||
|
||||
// Branch character
|
||||
branch := "├─"
|
||||
childPrefix := "│ "
|
||||
if isLast {
|
||||
branch = "└─"
|
||||
childPrefix = " "
|
||||
}
|
||||
|
||||
// Main line: branch + description + stats
|
||||
var stats []string
|
||||
if p.ToolUses > 0 {
|
||||
stats = append(stats, fmt.Sprintf("%d tool uses", p.ToolUses))
|
||||
}
|
||||
if p.Tokens > 0 {
|
||||
stats = append(stats, formatTokens(p.Tokens))
|
||||
}
|
||||
|
||||
line := sToolOutput.Render(branch+" ") + sText.Render(p.Description)
|
||||
if len(stats) > 0 {
|
||||
line += sToolResult.Render(" · "+strings.Join(stats, " · "))
|
||||
}
|
||||
lines = append(lines, line)
|
||||
|
||||
// Activity sub-line
|
||||
var activity string
|
||||
if p.Done {
|
||||
if p.Error != "" {
|
||||
activity = sError.Render("Error: " + p.Error)
|
||||
} else {
|
||||
dur := p.Duration.Round(time.Millisecond)
|
||||
activity = sToolOutput.Render(fmt.Sprintf("Done (%s)", dur))
|
||||
}
|
||||
} else {
|
||||
activity = p.Activity
|
||||
if activity == "" {
|
||||
activity = "working…"
|
||||
}
|
||||
activity = sToolResult.Render(activity)
|
||||
}
|
||||
lines = append(lines, sToolResult.Render(childPrefix+"└─ ")+activity)
|
||||
}
|
||||
|
||||
lines = append(lines, "") // spacing after tree
|
||||
return lines
|
||||
}
|
||||
|
||||
func formatTokens(tokens int) string {
|
||||
if tokens >= 1_000_000 {
|
||||
return fmt.Sprintf("%.1fM tokens", float64(tokens)/1_000_000)
|
||||
}
|
||||
if tokens >= 1_000 {
|
||||
return fmt.Sprintf("%.1fk tokens", float64(tokens)/1_000)
|
||||
}
|
||||
return fmt.Sprintf("%d tokens", tokens)
|
||||
}
|
||||
|
||||
func (m Model) renderSeparators() (string, string) {
|
||||
lineColor := cSurface // default dim
|
||||
modeLabel := ""
|
||||
@@ -791,10 +900,11 @@ func (m Model) renderSeparators() (string, string) {
|
||||
modeLabel = "🔒 " + modeLabel
|
||||
}
|
||||
|
||||
// Permission pending — flash the line
|
||||
// Permission pending — flash the line with command summary
|
||||
if m.permPending {
|
||||
lineColor = cRed
|
||||
modeLabel = "⚠ " + m.permToolName + " [y/n]"
|
||||
hint := shortPermHint(m.permToolName, m.permArgs)
|
||||
modeLabel = "⚠ " + hint + " [y/n]"
|
||||
}
|
||||
|
||||
lineStyle := lipgloss.NewStyle().Foreground(lineColor)
|
||||
@@ -921,6 +1031,77 @@ func (m Model) injectSystemContext(text string) {
|
||||
}
|
||||
}
|
||||
|
||||
// shortPermHint returns a compact string for the separator bar (e.g., "bash: find . -name '*.go'").
|
||||
func shortPermHint(toolName string, args json.RawMessage) string {
|
||||
switch toolName {
|
||||
case "bash":
|
||||
var a struct{ Command string }
|
||||
if json.Unmarshal(args, &a) == nil && a.Command != "" {
|
||||
cmd := a.Command
|
||||
if len(cmd) > 50 {
|
||||
cmd = cmd[:50] + "…"
|
||||
}
|
||||
return "bash: " + cmd
|
||||
}
|
||||
case "fs.write", "fs_write":
|
||||
var a struct {
|
||||
Path string `json:"file_path"`
|
||||
}
|
||||
if json.Unmarshal(args, &a) == nil && a.Path != "" {
|
||||
return "write: " + a.Path
|
||||
}
|
||||
case "fs.edit", "fs_edit":
|
||||
var a struct {
|
||||
Path string `json:"file_path"`
|
||||
}
|
||||
if json.Unmarshal(args, &a) == nil && a.Path != "" {
|
||||
return "edit: " + a.Path
|
||||
}
|
||||
}
|
||||
return toolName
|
||||
}
|
||||
|
||||
// formatPermissionPrompt builds a readable prompt showing what the tool wants to do.
|
||||
func formatPermissionPrompt(toolName string, args json.RawMessage) string {
|
||||
var detail string
|
||||
|
||||
switch toolName {
|
||||
case "bash":
|
||||
var a struct{ Command string }
|
||||
if json.Unmarshal(args, &a) == nil && a.Command != "" {
|
||||
cmd := a.Command
|
||||
if len(cmd) > 120 {
|
||||
cmd = cmd[:120] + "…"
|
||||
}
|
||||
detail = cmd
|
||||
}
|
||||
case "fs.write", "fs_write":
|
||||
var a struct {
|
||||
Path string `json:"file_path"`
|
||||
}
|
||||
if json.Unmarshal(args, &a) == nil && a.Path != "" {
|
||||
detail = a.Path
|
||||
}
|
||||
case "fs.edit", "fs_edit":
|
||||
var a struct {
|
||||
Path string `json:"file_path"`
|
||||
}
|
||||
if json.Unmarshal(args, &a) == nil && a.Path != "" {
|
||||
detail = a.Path
|
||||
}
|
||||
default:
|
||||
// Generic: try to extract a readable summary from args
|
||||
if len(args) > 0 && len(args) < 200 {
|
||||
detail = string(args)
|
||||
}
|
||||
}
|
||||
|
||||
if detail != "" {
|
||||
return fmt.Sprintf("⚠ %s wants to execute: %s [y/n]", toolName, detail)
|
||||
}
|
||||
return fmt.Sprintf("⚠ %s wants to execute [y/n]", toolName)
|
||||
}
|
||||
|
||||
func detectGitBranch() string {
|
||||
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
|
||||
out, err := cmd.Output()
|
||||
|
||||
@@ -91,6 +91,9 @@ var (
|
||||
|
||||
sDiffRemove = lipgloss.NewStyle().
|
||||
Foreground(cRed)
|
||||
|
||||
sText = lipgloss.NewStyle().
|
||||
Foreground(cText)
|
||||
)
|
||||
|
||||
// Status bar
|
||||
|
||||
Reference in New Issue
Block a user