Skip to content

Commit 7496c66

Browse files
authored
Merge pull request #96 from cnjack/feat/web-parallel-tasks
feat(web): parallel tasks + sidebar filter/sort/group, branch-switch safety, provider icons
2 parents b16b732 + e71dbd3 commit 7496c66

51 files changed

Lines changed: 4089 additions & 1152 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

internal/agent/budget.go

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -66,14 +66,31 @@ func (b *BudgetManager) Track(promptTokens, completionTokens int64) BudgetStatus
6666

6767
b.promptTokens += promptTokens
6868
b.completionTokens += completionTokens
69-
70-
inputCost := float64(promptTokens) * b.pricing.InputPer1M / 1_000_000
71-
outputCost := float64(completionTokens) * b.pricing.OutputPer1M / 1_000_000
72-
b.totalCost += inputCost + outputCost
69+
b.totalCost += b.costLocked(promptTokens, completionTokens, 0)
7370

7471
return b.statusLocked()
7572
}
7673

74+
// costLocked computes the USD cost of the given token counts, charging the
75+
// cached (cache-read) subset of the prompt at the discounted CacheRead rate when
76+
// the registry has it (else at the full input rate). Caller must hold the lock.
77+
func (b *BudgetManager) costLocked(promptTokens, completionTokens, cachedTokens int64) float64 {
78+
if cachedTokens < 0 {
79+
cachedTokens = 0
80+
}
81+
if cachedTokens > promptTokens {
82+
cachedTokens = promptTokens
83+
}
84+
cacheRate := b.pricing.CacheReadPer1M
85+
if cacheRate <= 0 {
86+
cacheRate = b.pricing.InputPer1M // no discount data: bill cached at full price
87+
}
88+
inputCost := float64(promptTokens-cachedTokens)*b.pricing.InputPer1M/1_000_000 +
89+
float64(cachedTokens)*cacheRate/1_000_000
90+
outputCost := float64(completionTokens) * b.pricing.OutputPer1M / 1_000_000
91+
return inputCost + outputCost
92+
}
93+
7794
// Check returns the current budget status and whether the budget has been exceeded.
7895
func (b *BudgetManager) Check() (BudgetStatus, bool) {
7996
b.mu.RLock()
@@ -150,17 +167,26 @@ func (m *budgetMiddleware) AfterModelRewriteState(
150167
mc *adk.ModelContext,
151168
) (context.Context, *adk.ChatModelAgentState, error) {
152169
var promptTokens, completionTokens int64
170+
var sessionPrompt, sessionCompletion, sessionCached int64
153171
if m.tokenUsage != nil {
154-
promptTokens, completionTokens, _ = m.tokenUsage.Get()
172+
// Per-turn delta for the per-agent-turn TOKEN cap (max_tokens_per_turn):
173+
// runner.BeginTurn sets the baseline at turn start. Reading cumulative
174+
// Get() here made the "per turn" cap behave as a session total.
175+
promptTokens, completionTokens, _ = m.tokenUsage.TurnUsage()
176+
// Session-cumulative for the COST cap (max_cost_per_session): cost must
177+
// accumulate across turns, not reset each turn.
178+
full := m.tokenUsage.GetFull()
179+
sessionPrompt = int64(full.PromptTokens)
180+
sessionCompletion = int64(full.CompletionTokens)
181+
sessionCached = int64(full.CachedTokens)
155182
}
156183

157-
// Sync budget manager with per-agent token tracker values.
184+
// promptTokens/completionTokens drive the per-turn token cap; totalCost is the
185+
// session-cumulative cost (cached subset billed at the cache-read rate).
158186
m.manager.mu.Lock()
159187
m.manager.promptTokens = promptTokens
160188
m.manager.completionTokens = completionTokens
161-
inputCost := float64(promptTokens) * m.manager.pricing.InputPer1M / 1_000_000
162-
outputCost := float64(completionTokens) * m.manager.pricing.OutputPer1M / 1_000_000
163-
m.manager.totalCost = inputCost + outputCost
189+
m.manager.totalCost = m.manager.costLocked(sessionPrompt, sessionCompletion, sessionCached)
164190
m.manager.mu.Unlock()
165191

166192
status := m.manager.Status()

internal/agent/budget_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package agent
2+
3+
import (
4+
"testing"
5+
6+
"github.com/cnjack/jcode/internal/config"
7+
internalmodel "github.com/cnjack/jcode/internal/model"
8+
)
9+
10+
// TestBudgetCost_CacheReadDiscount verifies the cached subset of the prompt is
11+
// billed at the discounted cache-read rate when the registry has it, and at the
12+
// full input rate otherwise (S3).
13+
func TestBudgetCost_CacheReadDiscount(t *testing.T) {
14+
bm := NewBudgetManager(nil, internalmodel.ModelPricing{InputPer1M: 10, OutputPer1M: 30, CacheReadPer1M: 1})
15+
// 1000 prompt (800 cached) + 100 completion.
16+
got := bm.costLocked(1000, 100, 800)
17+
want := float64(200)*10/1e6 + float64(800)*1/1e6 + float64(100)*30/1e6
18+
if got != want {
19+
t.Errorf("discounted cost = %v, want %v", got, want)
20+
}
21+
22+
// No cache pricing → cached billed at full input rate (no discount applied).
23+
plain := NewBudgetManager(nil, internalmodel.ModelPricing{InputPer1M: 10, OutputPer1M: 30})
24+
got = plain.costLocked(1000, 100, 800)
25+
want = float64(1000)*10/1e6 + float64(100)*30/1e6
26+
if got != want {
27+
t.Errorf("no-discount cost = %v, want %v", got, want)
28+
}
29+
30+
// cached clamped to prompt (never negative uncached portion).
31+
if c := bm.costLocked(100, 0, 999); c < 0 {
32+
t.Errorf("cached>prompt produced negative cost: %v", c)
33+
}
34+
}
35+
36+
// TestBudget_MaxTokensPerTurn verifies the per-turn cap trips on the (per-turn)
37+
// token total it is given, not before (C5: the middleware now feeds it the
38+
// turn delta rather than the session cumulative).
39+
func TestBudget_MaxTokensPerTurn(t *testing.T) {
40+
bm := NewBudgetManager(&config.BudgetConfig{MaxTokensPerTurn: 1000}, internalmodel.ModelPricing{})
41+
42+
bm.mu.Lock()
43+
bm.promptTokens, bm.completionTokens = 600, 300 // 900 < 1000
44+
bm.mu.Unlock()
45+
if _, exceeded := bm.Check(); exceeded {
46+
t.Error("900 tokens should not exceed a 1000 per-turn cap")
47+
}
48+
49+
bm.mu.Lock()
50+
bm.completionTokens = 500 // 1100 >= 1000
51+
bm.mu.Unlock()
52+
if _, exceeded := bm.Check(); !exceeded {
53+
t.Error("1100 tokens should exceed a 1000 per-turn cap")
54+
}
55+
}

internal/agent/compaction.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -179,11 +179,14 @@ func (m *compactionMiddleware) BeforeModelRewriteState(
179179
state *adk.ChatModelAgentState,
180180
mc *adk.ModelContext,
181181
) (context.Context, *adk.ChatModelAgentState, error) {
182-
// Estimate current token usage from the per-agent tracker.
182+
// Estimate current context occupancy from the per-agent tracker. Use the LAST
183+
// call's total (GetLastTotal), NOT the cumulative prompt sum: the agent
184+
// re-sends the whole context on every tool-loop call, so cumulative prompt
185+
// (e.g. 20k×5=100k) would trip compaction far too early while the real window
186+
// is still ~20k.
183187
var currentTokens int
184188
if m.tokenUsage != nil {
185-
promptTokens, _, _ := m.tokenUsage.Get()
186-
currentTokens = int(promptTokens)
189+
currentTokens = int(m.tokenUsage.GetLastTotal())
187190
}
188191

189192
if !m.strategy.ShouldCompact(currentTokens, m.contextLimit) {

internal/command/interactive.go

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ func (s *interactiveState) createAgent() (*adk.ChatModelAgent, error) {
185185
s.summCapture.Capture(summary.Content, contextN)
186186
config.Logger().Printf("[summarization] Finalize: compacted %d context messages", contextN)
187187
if s.agentTokenUsage != nil {
188-
s.agentTokenUsage.Reset()
188+
s.agentTokenUsage.ResetContext()
189189
}
190190
return append(systemMsgs, summary), nil
191191
},
@@ -228,7 +228,8 @@ func (s *interactiveState) createAgent() (*adk.ChatModelAgent, error) {
228228
if s.cfg.Budget != nil {
229229
providerName, modelName := s.cfg.GetProviderModel()
230230
inputPer1M, outputPer1M := s.registry.GetModelCost(providerName, modelName)
231-
pricing := internalmodel.ModelPricing{InputPer1M: inputPer1M, OutputPer1M: outputPer1M}
231+
cacheReadPer1M, _ := s.registry.GetModelCacheCost(providerName, modelName)
232+
pricing := internalmodel.ModelPricing{InputPer1M: inputPer1M, OutputPer1M: outputPer1M, CacheReadPer1M: cacheReadPer1M}
232233
budgetManager := agent.NewBudgetManager(s.cfg.Budget, pricing)
233234
budgetMw := agent.NewBudgetMiddleware(budgetManager, s.agentTokenUsage, func(status agent.BudgetStatus) {
234235
config.Logger().Printf("[budget] warning level=%d cost=%.4f", status.WarningLevel, status.EstimatedCost)
@@ -240,7 +241,7 @@ func (s *interactiveState) createAgent() (*adk.ChatModelAgent, error) {
240241
compactionStrategy := agent.NewThresholdCompactionStrategy(compactThreshold, s.chatModel, 6)
241242
compactionMw := agent.NewCompactionMiddleware(compactionStrategy, contextLimit, s.agentTokenUsage, func(savedTokens int) {
242243
if s.agentTokenUsage != nil {
243-
s.agentTokenUsage.Reset()
244+
s.agentTokenUsage.ResetContext()
244245
}
245246
if s.p != nil {
246247
s.p.Send(tui.CompactDoneMsg{OldTokens: 0, NewTokens: 0})
@@ -318,7 +319,7 @@ func (s *interactiveState) applyModeSwitch(newMode tui.AgentMode) {
318319
config.Logger().Printf("[plan] agent creation failed: %v", err)
319320
}
320321
if s.agentTokenUsage != nil {
321-
s.agentTokenUsage.Reset()
322+
s.agentTokenUsage.ResetContext()
322323
}
323324
// Sync the TUI mode pill with the resulting unified mode (covers the
324325
// plan-completion revert to Normal, which the user did not trigger directly).
@@ -592,6 +593,10 @@ func (s *interactiveState) handleConfig(cfgMsg *config.Config) {
592593
return
593594
}
594595
s.chatModel = newChatModel
596+
// Attribute subsequent usage to the newly selected model.
597+
if s.rec != nil {
598+
s.rec.SetModel(newModelName)
599+
}
595600

596601
// Rebuild system prompt and tools to reflect config changes (e.g., SSH aliases)
597602
if s.agentMode == tui.ModePlanning {
@@ -622,7 +627,7 @@ func (s *interactiveState) handleCompact() {
622627
s.rec.RecordCompact(s.history[0].Content, oldLen-len(s.history))
623628
}
624629
if s.agentTokenUsage != nil {
625-
s.agentTokenUsage.Reset()
630+
s.agentTokenUsage.ResetContext()
626631
}
627632
s.p.Send(tui.CompactDoneMsg{
628633
OldTokens: oldTokens,
@@ -665,6 +670,9 @@ func (s *interactiveState) handleAddModel() {
665670
return
666671
}
667672
s.chatModel = newChatModel
673+
if s.rec != nil {
674+
s.rec.SetModel(newModelName)
675+
}
668676
if newAg, agErr := s.createAgent(); agErr == nil {
669677
s.ag = newAg
670678
}

0 commit comments

Comments
 (0)