Skip to content

Commit 1a2ea82

Browse files
committed
runtime: lane exhaustion raises a consented fallback card (P4)
When a subscription/key lane hits its usage window, the turn pauses on a card: stop (default) or continue on memcode credits — the same never- silently-move-spend doctrine as the BYOK consent. The choice is sticky per vendor for the session (a one-line notice on auto-apply), the retry reissues the same call with a turn-scoped gateway bypass honored by lane dispatch, and headless/sub-agent sessions fail with the reason and reset time.
1 parent 981bae6 commit 1a2ea82

6 files changed

Lines changed: 141 additions & 5 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package runtime
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/memcode-ai/memcode/internal/llm"
8+
"github.com/memcode-ai/memcode/internal/provider"
9+
)
10+
11+
func exhErr(canFallback bool) *provider.ErrLaneExhausted {
12+
return &provider.ErrLaneExhausted{
13+
Lane: provider.LaneInfo{Vendor: "anthropic", Name: "claude-sub", Kind: "sub"},
14+
Status: 429,
15+
CanFallback: canFallback,
16+
}
17+
}
18+
19+
// The exhaustion card: stop is the default, "continue" flips the turn onto
20+
// the gateway bypass and sticks for the vendor, headless fails closed.
21+
func TestConsentLaneFallback(t *testing.T) {
22+
s := &Session{purpose: llm.MainLoop}
23+
24+
// No gateway → nothing to offer.
25+
if s.consentLaneFallback(context.Background(), exhErr(false)) {
26+
t.Fatal("consented with no fallback path")
27+
}
28+
29+
// Headless (no asker) → fail closed.
30+
if s.consentLaneFallback(context.Background(), exhErr(true)) {
31+
t.Fatal("headless session consented")
32+
}
33+
34+
// Explicit continue → bypass set + sticky.
35+
s.ask = func(ctx context.Context, r AskRequest) AskResponse {
36+
if r.Options[0].Label != "Stop the turn" {
37+
t.Fatalf("stop must be the FIRST (default) option, got %q", r.Options[0].Label)
38+
}
39+
return AskResponse{Answer: "Continue on memcode credits"}
40+
}
41+
s.turn = newTurnState()
42+
if !s.consentLaneFallback(context.Background(), exhErr(true)) {
43+
t.Fatal("explicit continue refused")
44+
}
45+
if s.turn.laneBypass != "gateway" {
46+
t.Fatalf("laneBypass = %q", s.turn.laneBypass)
47+
}
48+
49+
// Sticky: a second exhaustion for the vendor never re-asks.
50+
s.ask = func(ctx context.Context, r AskRequest) AskResponse {
51+
t.Fatal("sticky choice re-asked")
52+
return AskResponse{}
53+
}
54+
s.turn = newTurnState()
55+
if !s.consentLaneFallback(context.Background(), exhErr(true)) {
56+
t.Fatal("sticky continue not applied")
57+
}
58+
59+
// Empty answer (Esc) → stop, and THAT sticks too.
60+
s2 := &Session{purpose: llm.MainLoop, turn: newTurnState()}
61+
s2.ask = func(ctx context.Context, r AskRequest) AskResponse { return AskResponse{} }
62+
if s2.consentLaneFallback(context.Background(), exhErr(true)) {
63+
t.Fatal("empty answer consented")
64+
}
65+
s2.ask = func(ctx context.Context, r AskRequest) AskResponse {
66+
t.Fatal("sticky stop re-asked")
67+
return AskResponse{}
68+
}
69+
if s2.consentLaneFallback(context.Background(), exhErr(true)) {
70+
t.Fatal("sticky stop consented")
71+
}
72+
}

internal/agent/runtime/loop.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ func (s *Session) runLoop(ctx context.Context, sys promptSpec, messages *[]wire.
234234
Effort: eff, MaxTokens: maxTok,
235235
Difficulty: s.turnDifficulty, // the judge's tier verdict → the ladder's difficulty input
236236
BillingLane: s.turnBillingLane(), // "" normally; "credits" after an explicit BYOK-failure consent
237+
LaneBypass: s.turn.laneBypass, // "" normally; "gateway" after a consented lane-exhaustion fallback
237238
// Escalation signals only the CLI can see (self-heal, room friction,
238239
// high-risk surfaces) — inputs to the CLI's own semantic ladder.
239240
RoutingHint: s.turnRoutingHint(),
@@ -307,6 +308,19 @@ func (s *Session) runLoop(ctx context.Context, sys promptSpec, messages *[]wire.
307308
s.printf("\n■ %s\n", metaStyle.Render("Your API key was rejected — fix or remove it with /apikeys"))
308309
return iterations, false, nil
309310
}
311+
// A subscription/own-key lane hit its usage window. Same doctrine as
312+
// BYOK: never SILENTLY move spend — the only path onto credits is an
313+
// explicit per-vendor choice, sticky for this session. Headless
314+
// sessions fail with the reason (and the reset time when known).
315+
var laneExh *provider.ErrLaneExhausted
316+
if errors.As(err, &laneExh) {
317+
if s.consentLaneFallback(ctx, laneExh) {
318+
s.printf("\n%s\n", metaStyle.Render("⊙ "+laneExh.Error()+" — continuing on memcode credits (session choice; /status to review)"))
319+
continue
320+
}
321+
s.printf("\n■ %s\n", metaStyle.Render(laneExh.Error()+" — turn stopped. Pick another family with /model, or wait for the window."))
322+
return iterations, false, nil
323+
}
310324
// Token rejected (401): the SESSION is signed out (expired or revoked
311325
// key). Disconnect the provider so the front-end's signed-out gate
312326
// takes over (no more doomed dispatches), and say what fixes it.
@@ -1316,6 +1330,43 @@ func estimateTokens(chars int) int {
13161330
return chars / 4
13171331
}
13181332

1333+
// consentLaneFallback asks (once per vendor per session) how to continue when
1334+
// a lane's subscription/key hits its usage window. True = serve this and
1335+
// future exhausted turns for that vendor on memcode credits via the gateway.
1336+
func (s *Session) consentLaneFallback(ctx context.Context, exh *provider.ErrLaneExhausted) bool {
1337+
if !exh.CanFallback {
1338+
return false // no gateway base — nothing to fall back to
1339+
}
1340+
if s.laneFallback == nil {
1341+
s.laneFallback = map[string]string{}
1342+
}
1343+
if choice, ok := s.laneFallback[exh.Lane.Vendor]; ok {
1344+
if choice == "gateway" {
1345+
s.turn.laneBypass = "gateway"
1346+
return true
1347+
}
1348+
return false
1349+
}
1350+
if s.ask == nil || s.purpose != llm.MainLoop {
1351+
return false // headless / sub-agent: fail with the reason
1352+
}
1353+
resp := s.ask(ctx, AskRequest{
1354+
Question: exh.Error() + ". How should this session continue when it's exhausted?",
1355+
Options: []AskOption{
1356+
{Label: "Stop the turn", Description: "Wait for the window, or /model another family"},
1357+
{Label: "Continue on memcode credits", Description: "This and future exhausted turns — billed to your credit balance"},
1358+
},
1359+
})
1360+
ans := strings.ToLower(strings.TrimSpace(resp.Answer))
1361+
if strings.HasPrefix(ans, "continue") {
1362+
s.laneFallback[exh.Lane.Vendor] = "gateway"
1363+
s.turn.laneBypass = "gateway"
1364+
return true
1365+
}
1366+
s.laneFallback[exh.Lane.Vendor] = "stop"
1367+
return false
1368+
}
1369+
13191370
// turnBillingLane returns the billing-lane extension for this turn's model
13201371
// calls: "credits" only after the user explicitly consented to serve this
13211372
// turn on memcode credits (a BYOK key failure); "" otherwise (byok-preferred,

internal/agent/runtime/runtime.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,11 +76,12 @@ type Session struct {
7676
purpose llm.Purpose // ledger purpose for THIS session's main loop (main_loop, or explore for scout sub-agents)
7777
root string
7878
model string
79-
scoutModel string // model for read-only explore sub-agents (cheap; Luna by default)
80-
vendor string // per-session strong-tier vendor ("" = configured default; set by /model)
81-
pin string // pinned model label ("" = Automatic; set by /model — every real request serves this model)
82-
lastServedModel string // last turn's serving model — cross-family thinking-block hygiene (loop.go)
83-
pinWindow int // the pin's context window from the picker list (0 = unknown; sizes the meter before the first serve)
79+
scoutModel string // model for read-only explore sub-agents (cheap; Luna by default)
80+
vendor string // per-session strong-tier vendor ("" = configured default; set by /model)
81+
pin string // pinned model label ("" = Automatic; set by /model — every real request serves this model)
82+
lastServedModel string // last turn's serving model — cross-family thinking-block hygiene (loop.go)
83+
laneFallback map[string]string // vendor → sticky exhaustion choice for this session ("gateway" | "stop")
84+
pinWindow int // the pin's context window from the picker list (0 = unknown; sizes the meter before the first serve)
8485
mode permissions.Mode
8586
modeMu sync.RWMutex // guards mode: the TUI goroutine cycles it (Shift+Tab // /mode) while the engine reads it at the permission gate
8687
personality string // chosen voice (built-in key or custom text); travels as a fact, tone-only

internal/agent/runtime/turnstate.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type turnState struct {
2121
firstBreak string // the FIRST broken-edit nudge this turn — the failure evidence for lesson distillation
2222
lessonDone bool // a lesson was already distilled this turn (fire once)
2323
billingCredits bool // user consented to serve THIS turn on memcode credits after a BYOK key failure
24+
laneBypass string // "gateway" after a consented lane-exhaustion fallback — this turn serves off-lane
2425
}
2526

2627
// newTurnState returns a fresh per-turn state (with an initialized gather tracker).

internal/provider/dispatch.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ import (
1616
func (l *Lazy) route(r wire.Request) (wire.Request, *conn, *lane, error) {
1717
base := l.c.Load()
1818
lanes := l.laneSet()
19+
// A consented exhaustion choice bypasses lane dispatch for this turn.
20+
if r.LaneBypass == "gateway" {
21+
if base == nil {
22+
return r, nil, nil, ErrNotLoggedIn
23+
}
24+
return r, base, nil, nil
25+
}
1926
if len(lanes) == 0 {
2027
if base == nil {
2128
return r, nil, nil, ErrNotLoggedIn

internal/wire/wire.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,10 @@ type Request struct {
239239
// from that extension and the gateway ENFORCES it — it never silently
240240
// reroutes between the user's keys and credits. NOT marshaled on this type.
241241
BillingLane string `json:"-"`
242+
// LaneBypass forces a turn OFF its family lane after an explicit,
243+
// consented exhaustion choice: "gateway" serves it on the hosted base.
244+
// Client-side routing state only — never serialized to any wire.
245+
LaneBypass string `json:"-"`
242246
}
243247

244248
// Response is a model completion result, plus the serving telemetry the footer/

0 commit comments

Comments
 (0)