Skip to content

Commit efcfb5c

Browse files
clinteckerclaude
andcommitted
feat(#353): cost_exceeded_action:fail — make per-node cost caps safe
A per-node max_cost_usd breach always routed OutcomeRetry, which re-runs (and multiplies the cost of) an expensive, uncached node — so capping the runaway review lane from the case study would burn up to 3× the cap before giving up, often worse than uncapped. That retry-multiplier is why capping the reviewers couldn't be done safely. New `cost_exceeded_action: fail` node attr routes the node's fail edges immediately on a cost breach (no retry), so a cap is safe to place on a lane that should escalate rather than re-run. In build_product the reviewers' fail path already routes to EscalateReview (a human gate), so a capped-then-failed reviewer escalates cleanly. Default stays "retry" (unchanged behavior). This ships the enabling primitive; the cap *value* on build_product's reviewers needs real-run cost data to calibrate (too low → false escalations), so that .dip edit is deliberately left for a run rather than guessed blind. Combined with the already-shipped `tracker diagnose` cost-asymmetry detector, #353 now has both the "make it visible" and the "make a cap safe" halves. Tests: cost_exceeded_action=fail routes OutcomeFail (not Retry) while still setting node_cost_exceeded for fail-edge conditions. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01GVTTPqhd6w2tzmgwEqJpL3
1 parent 620868f commit efcfb5c

4 files changed

Lines changed: 55 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- **`cost_exceeded_action: fail` — safe per-node cost caps (#353).** A per-node
13+
cost ceiling (`max_cost_usd`) previously always routed `retry` on breach, which
14+
re-runs (and multiplies the cost of) an expensive, uncached node — so capping a
15+
runaway review lane could make it *worse*. The new `cost_exceeded_action: fail`
16+
attr routes the node's fail edges immediately on breach (no retry), so a cap is
17+
safe to place on a lane that should escalate rather than re-run. Default stays
18+
`retry` (unchanged). This is the primitive #353's reviewer-cap fix needs;
19+
choosing the cap value + wiring it into `build_product`'s reviewers is left to a
20+
real run to calibrate.
21+
1222
- **Test-fidelity check: `tracker verify-tests [dir]` (#489, core).** Flags Go
1323
test functions that share a body — byte-for-byte duplicates and near-duplicates
1424
that differ only in literal values — the exact "a required test is a copy of

pipeline/handlers/codergen.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -527,8 +527,14 @@ func (h *CodergenHandler) buildNodeCostExceededOutcome(node *pipeline.Node, prom
527527
Timestamp: time.Now(),
528528
})
529529
}
530+
// Default retries within budget; cost_exceeded_action: fail routes fail edges
531+
// immediately so a cap doesn't re-run (and multiply) an expensive node (#353).
532+
status := pipeline.OutcomeRetry
533+
if node.AgentConfig(h.graphAttrs).CostExceededAction == "fail" {
534+
status = pipeline.OutcomeFail
535+
}
530536
outcome := pipeline.Outcome{
531-
Status: pipeline.OutcomeRetry,
537+
Status: status,
532538
ContextUpdates: map[string]string{
533539
pipeline.ContextKeyLastResponse: msg,
534540
pipeline.ContextKeyResponsePrefix + node.ID: msg,

pipeline/handlers/codergen_guards_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,35 @@ func TestCodergenNodeCostExceededRoutesToRetry(t *testing.T) {
5050
}
5151
}
5252

53+
func TestCodergenCostExceededActionFailRoutesToFail(t *testing.T) {
54+
// cost_exceeded_action: fail → the cap routes fail edges immediately (no
55+
// retry), so an expensive node can't be re-run and multiply the cost (#353).
56+
client := &scriptedCompleter{responses: []*llm.Response{
57+
truncatedCostResponse(0.006),
58+
truncatedCostResponse(0.006),
59+
}}
60+
h := NewCodergenHandler(client, t.TempDir())
61+
node := &pipeline.Node{
62+
ID: "review", Shape: "box", Handler: "codergen",
63+
Attrs: map[string]string{
64+
"prompt": "review something expensive",
65+
"max_cost_usd": "0.01",
66+
"cost_exceeded_action": "fail",
67+
},
68+
}
69+
outcome, err := h.Execute(context.Background(), node, pipeline.NewPipelineContext())
70+
if err != nil {
71+
t.Fatalf("unexpected error: %v", err)
72+
}
73+
if outcome.Status != pipeline.OutcomeFail {
74+
t.Errorf("want %q with cost_exceeded_action=fail, got %q", pipeline.OutcomeFail, outcome.Status)
75+
}
76+
// The guard flag is still set so fail-edge routing can condition on it.
77+
if outcome.ContextUpdates[pipeline.ContextKeyNodeCostExceeded] != "true" {
78+
t.Errorf("node_cost_exceeded should still be set, got %q", outcome.ContextUpdates[pipeline.ContextKeyNodeCostExceeded])
79+
}
80+
}
81+
5382
func TestCodergenNoProgressDetectedRoutesToRetry(t *testing.T) {
5483
// no_progress_turns=2: two consecutive truncated turns without tool calls → no-progress fires.
5584
client := &scriptedCompleter{responses: []*llm.Response{

pipeline/node_config.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,14 @@ type AgentNodeConfig struct {
3333
MaxBudgetUSD float64
3434
MaxCostUSD float64 // #304: per-node cost ceiling in USD; 0 = unlimited
3535
NoProgressTurns int // #304: halt after K consecutive tool-call-free turns; 0 = disabled
36-
PermissionMode string
37-
ACPAgent string
36+
// CostExceededAction controls what happens when MaxCostUSD is breached:
37+
// "retry" (default — re-run within retry budget) or "fail" (route fail edges
38+
// immediately, no retry). "fail" makes a cap safe on an expensive, uncached
39+
// node whose retries would multiply the cost (#353) — e.g. a review lane that
40+
// should escalate rather than re-run.
41+
CostExceededAction string
42+
PermissionMode string
43+
ACPAgent string
3844

3945
// ToolAccess restricts the agent's tool surface. When non-empty (any
4046
// value), the runtime registers zero tools, sets ToolChoice=none on
@@ -138,6 +144,7 @@ func (n *Node) AgentConfig(graphAttrs map[string]string) AgentNodeConfig {
138144
cfg.PermissionMode = n.Attrs["permission_mode"]
139145
cfg.ToolAccess = n.Attrs["tool_access"]
140146
cfg.ACPAgent = n.Attrs["acp_agent"]
147+
cfg.CostExceededAction = n.Attrs["cost_exceeded_action"]
141148
cfg.SystemPrompt = n.Attrs["system_prompt"]
142149
cfg.ResponseFormat = n.Attrs["response_format"]
143150
cfg.ResponseSchema = n.Attrs["response_schema"]

0 commit comments

Comments
 (0)