From 5568764d9639d0c820b06bd177255f1baf622c22 Mon Sep 17 00:00:00 2001 From: Drew Bailey Date: Mon, 17 Aug 2026 13:00:01 -0700 Subject: [PATCH 1/8] feat(routing): route harness-protocol turns up, never down; auto-detect claimed-tool-unavailable Adds three deterministic turn classifications (harness_meta, sub_agent_harness_meta, recovery) and a post-routing clamp that escalates those turns to claude-opus-5 unless the resolved decision is already a Claude-family TierHigh model. Sub-agent harness turns no longer take the cheap sub-agent hard-pin; usage-bypass, operator hard pins, /force-model pins, and loop escalation all outrank the clamp; kill switch is ROUTER_HARNESS_ESCALATION_ENABLED (default on). The policy sidecar keeps a stable turn-type vocabulary via TurnType.Base() while telemetry records the full new values. Also adds a capture-gated post-stream detector that writes source=auto rating=down router_feedback rows when a response claims a tool is unavailable that the request actually declared - persist-only, LRU-deduped per (session, role, tool). Motivated by a large agentic session where a sub-agent turn "Load EnterPlanMode tool schema" was served by a flash-tier model that could not drive the deferred-tool protocol, silently losing plan mode. Co-Authored-By: Weave Router --- cmd/router/main.go | 5 + internal/proxy/claimed_tool_unavailable.go | 332 ++++++++++++++++++ .../claimed_tool_unavailable_internal_test.go | 321 +++++++++++++++++ internal/proxy/feedback.go | 5 +- internal/proxy/harness_escalation.go | 170 +++++++++ .../proxy/harness_escalation_internal_test.go | 203 +++++++++++ internal/proxy/service.go | 48 ++- internal/proxy/turnloop.go | 55 ++- internal/router/catalog/family.go | 12 + internal/router/catalog/family_test.go | 23 ++ internal/router/turntype/AGENTS.md | 5 + internal/router/turntype/CLAUDE.md | 5 + internal/router/turntype/detect.go | 64 ++++ internal/router/turntype/detect_test.go | 132 +++++++ internal/router/turntype/harness.go | 193 ++++++++++ internal/translate/claudecode_tool_filter.go | 16 + internal/translate/force_model.go | 5 + internal/translate/lastuser_toolresult.go | 99 ++++++ .../translate/lastuser_toolresult_test.go | 134 +++++++ 19 files changed, 1809 insertions(+), 18 deletions(-) create mode 100644 internal/proxy/claimed_tool_unavailable.go create mode 100644 internal/proxy/claimed_tool_unavailable_internal_test.go create mode 100644 internal/proxy/harness_escalation.go create mode 100644 internal/proxy/harness_escalation_internal_test.go create mode 100644 internal/router/turntype/harness.go create mode 100644 internal/translate/lastuser_toolresult.go create mode 100644 internal/translate/lastuser_toolresult_test.go diff --git a/cmd/router/main.go b/cmd/router/main.go index cfeb7684b..7047a491e 100644 --- a/cmd/router/main.go +++ b/cmd/router/main.go @@ -585,6 +585,9 @@ func main() { // Enforcing text-repetition break ships enabled; the switch is the kill // switch if it ever false-positives on legit repeated narration. textRepetitionBreakEnabled := config.GetOr("ROUTER_TEXT_REPETITION_BREAK_ENABLED", "true") == "true" + // Harness-protocol escalation clamp ships enabled; the switch exists so a + // misdetection (turntype false positive) can be killed without a redeploy. + harnessEscalationEnabled := config.GetOr("ROUTER_HARNESS_ESCALATION_ENABLED", "true") == "true" plannerCfg := planner.EVConfig{ ThresholdUSD: parseEnvFloat("ROUTER_SWITCH_EV_THRESHOLD_USD", proxy.DefaultPlannerThresholdUSD), ExpectedRemainingTurns: parseEnvInt("ROUTER_SWITCH_EXPECTED_REMAINING_TURNS", proxy.DefaultPlannerExpectedRemainingTurns), @@ -817,6 +820,7 @@ func main() { WithBandSwap(bandSwapEnabled). WithLoopEscalationConfig(loopEscalationEnabled, loopEscalationHoldoutPct). WithLoopEscalationStore(repo.Telemetry). + WithHarnessEscalationConfig(harnessEscalationEnabled). WithSpiralShadowConfig(spiralShadowEnabled). WithSpiralShadowStore(repo.Telemetry). WithTextRepetitionBreak(textRepetitionBreakEnabled). @@ -834,6 +838,7 @@ func main() { logger.Info("Effort escalation configured", "enabled", effortEscalation) logger.Info("Cross-vendor Claude Code orchestration tools configured", "enabled", ccOrchToolsCrossVendor) logger.Info("Loop escalation configured", "enabled", loopEscalationEnabled, "holdout_pct", loopEscalationHoldoutPct) + logger.Info("Harness escalation configured", "enabled", harnessEscalationEnabled) logger.Info("Spiral shadow detector configured", "enabled", spiralShadowEnabled) logger.Info("Text-repetition break configured", "enabled", textRepetitionBreakEnabled) logger.Info("Planner configured", "enabled", plannerEnabled, "threshold_usd", plannerCfg.ThresholdUSD, "expected_remaining_turns", plannerCfg.ExpectedRemainingTurns, "tier_upgrade_enabled", plannerCfg.TierUpgradeEnabled, "cold_pin_follow_fresh", plannerCfg.ColdPinFollowFresh, "prefix_trim_free_switch", prefixTrimFreeSwitch, "routing_targets_count", len(routingTargets)) diff --git a/internal/proxy/claimed_tool_unavailable.go b/internal/proxy/claimed_tool_unavailable.go new file mode 100644 index 000000000..07b4cc07f --- /dev/null +++ b/internal/proxy/claimed_tool_unavailable.go @@ -0,0 +1,332 @@ +// The claimed-tool-unavailable detector flags a routed model that says a +// tool is not in its toolset while the request actually declared that tool — +// a detectable model failure. Post-stream: capture-gated (no respBody means +// no-op, e.g. self-hosted no-capture deploys). Persist-only — writes a +// source="auto" negative RouterFeedbackEvent offline; it never influences +// routing, pins, or the live reward loop (ML consumes the auto rows offline). +package proxy + +import ( + "bytes" + "context" + "strings" + "time" + + "workweave/router/internal/auth" + "workweave/router/internal/observability" + "workweave/router/internal/router/sessionpin" + + "github.com/google/uuid" + lru "github.com/hashicorp/golang-lru/v2/expirable" + "github.com/tidwall/gjson" +) + +// claimedUnavailablePhrases are the "claimed unavailable" tell-strings matched +// against the lowercased ±160-byte window around a declared tool name. Each is +// a loose substring: "not available" captures "is not available" / "not +// available in my toolset", "don't have access" covers "I don't have access". +// Keep this a package var so tests pin the exact list. +var claimedUnavailablePhrases = []string{ + "not available", + "isn't available", + "is unavailable", + "no such tool", + "not in my", + "not directly callable", + "not callable", + "don't have access", + "do not have access", + "have no access", + "no mechanism to load", + "not a loadable", + "not exposed", + "not in my available toolset", +} + +// claimedTool... knobs for the claimed-tool-unavailable tracker — size/TTL +// mirror the spiral detector's shape (spiral_detection.go:172-175). +const ( + // claimedToolFiredCacheSize bounds the per-replica dedup LRU. + claimedToolFiredCacheSize = 4096 + // claimedToolFiredCacheTTL is how long a fired (session, role, tool) + // stays suppressed on this replica. The durable once-per-event budget is + // cheap; this only trims repeat rows for very long sessions. + claimedToolFiredCacheTTL = 24 * time.Hour + // claimedToolWindowBytes is the lowercased window scanned around each + // declared tool-name occurrence. Long enough to span "There is no ... tool + // in my available toolset"-style sentences, short enough to not match a + // "not available" elsewhere in a long reply. + claimedToolWindowBytes = 160 + // claimedToolNoPrecedeBytes is how far before an occurrence "no " / + // "there is no " (the "no tool" signal) is checked. + claimedToolNoPrecedeBytes = 24 + // claimedToolMaxFindings caps findings per response so one reply can't + // spam the dedup keys. + claimedToolMaxFindings = 4 + // claimedToolScanMaxBytes bounds the text extracted from captured bytes. + claimedToolScanMaxBytes = 262144 +) + +// claimedToolTracker de-dupes automatic negative feedback fires per +// (session, role, tool) on this replica, mirroring spiralTracker's shape. +type claimedToolTracker struct { + fired *lru.LRU[string, struct{}] +} + +func newClaimedToolTracker() *claimedToolTracker { + return &claimedToolTracker{ + fired: lru.NewLRU[string, struct{}](claimedToolFiredCacheSize, nil, claimedToolFiredCacheTTL), + } +} + +func claimedToolFiredKey(sessionKey [sessionpin.SessionKeyLen]byte, role, tool string) string { + return string(sessionKey[:]) + "\x00" + role + "\x00" + tool +} + +// claimedToolUnavailableFromBody extracts the model's response text from the +// captured client-bound bytes — Anthropic SSE frames when streaming, a single +// JSON body otherwise — and reports the declared tools it claims unavailable. +// Malformed/truncated input fails open (returns no findings): the capture cap +// can cut mid-frame, and this detector must never error or panic. +func claimedToolUnavailableFromBody(respBody []byte, streaming bool, availableTools []string) []string { + if !streaming { + return detectClaimedToolUnavailable(nonStreamingText(respBody), availableTools) + } + var text strings.Builder + text.Grow(len(respBody) / 2) + scanCap := claimedToolScanMaxBytes + for _, rawLine := range bytes.Split(respBody, []byte("\n")) { + if !bytes.HasPrefix(rawLine, []byte("data: ")) { + continue + } + data := bytes.TrimRight(rawLine[len("data: "):], "\r") + if string(data) == "[DONE]" { + continue + } + frame := gjson.ParseBytes(data) + if frame.Get("type").String() != "content_block_delta" { + continue + } + if frame.Get("delta.type").String() != "text_delta" { + continue + } + delta := frame.Get("delta.text").String() + if delta == "" { + continue + } + if len(delta) > scanCap { + delta = delta[:scanCap] + } + text.WriteString(delta) + scanCap -= len(delta) + if scanCap <= 0 { + break + } + } + return detectClaimedToolUnavailable(text.String(), availableTools) +} + +// nonStreamingText gathers text from a single JSON response body: content +// blocks with type == "text". Any other shape yields no text (fail open). +func nonStreamingText(respBody []byte) string { + content := gjson.GetBytes(respBody, "content") + if !content.IsArray() { + return "" + } + var text strings.Builder + text.Grow(len(respBody) / 2) + scanCap := claimedToolScanMaxBytes + content.ForEach(func(_, block gjson.Result) bool { + if block.Get("type").String() != "text" { + return true + } + blockText := block.Get("text").String() + if len(blockText) > scanCap { + blockText = blockText[:scanCap] + } + text.WriteString(blockText) + scanCap -= len(blockText) + return scanCap > 0 + }) + return text.String() +} + +// detectClaimedToolUnavailable scans text for each declared tool name and +// reports the names the model claims unavailable. Name matching is +// case-SENSITIVE with word boundaries (a name nested inside a longer +// identifier does not count); around each occurrence a lowercased +// ±claimedToolWindowBytes window is checked against claimedUnavailablePhrases, +// or a "no "/"there is no " immediately preceding the name. Returns deduped +// findings capped at claimedToolMaxFindings. Pure — unit-test directly. +func detectClaimedToolUnavailable(text string, availableTools []string) []string { + if text == "" || len(availableTools) == 0 { + return nil + } + var findings []string + for _, tool := range availableTools { + if tool == "" { + continue + } + if containsClaimedUnavailable(text, tool) { + findings = append(findings, tool) + if len(findings) >= claimedToolMaxFindings { + break + } + } + } + return findings +} + +// containsClaimedUnavailable reports whether any word-boundary occurrence of +// tool in text sits next to an unavailable-claim. Iterates all occurrences: +// the model may mention the tool normally and deny it later in the same reply. +func containsClaimedUnavailable(text, tool string) bool { + lower := strings.ToLower(text) + for offset := 0; offset < len(text); { + idx := strings.Index(text[offset:], tool) + if idx < 0 { + break + } + idx += offset + start, end := idx, idx+len(tool) + if !leftIdentifierEdge(text, start) || !rightIdentifierEdge(text, end) { + offset = idx + len(tool) + continue + } + if windowClaimsUnavailable(lower, start, end) { + return true + } + offset = idx + len(tool) + } + return false +} + +// leftIdentifierEdge reports whether i is a clean left identifier edge: text +// start, or preceded by a byte that is not identifier-class. +func leftIdentifierEdge(s string, i int) bool { + return i == 0 || !isIdentifierClass(s[i-1]) +} + +// rightIdentifierEdge reports whether i (just past an occurrence) is a clean +// right identifier edge: text end, or followed by a byte that is not +// identifier-class. +func rightIdentifierEdge(s string, i int) bool { + return i >= len(s) || !isIdentifierClass(s[i]) +} + +// isIdentifierClass is the identifier-boundary class: ASCII letter, digit, or +// underscore. Bytes >= 0x80 count as boundaries (a surrounding non-ASCII +// character must never break a match), and single-byte reads avoid any +// partial-rune decoding on multibyte text. +func isIdentifierClass(b byte) bool { + return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_' +} + +// windowClaimsUnavailable checks the "no tool" precede signal (a "no " +// or "there is no " ending within claimedToolNoPrecedeBytes before the match) +// and the lowercased ±claimedToolWindowBytes window around the occurrence for +// any claimedUnavailablePhrases entry. +func windowClaimsUnavailable(lower string, occStart, occEnd int) bool { + // The direct precedent: "...no " / "...there is no " ending immediately + // before the name. Covers "no tool" without needing a window phrase. + before := occStart + if before > claimedToolNoPrecedeBytes { + before = claimedToolNoPrecedeBytes + } + if endsWithNoPhrase(lower[occStart-before : occStart]) { + return true + } + lo := occStart - claimedToolWindowBytes + if lo < 0 { + lo = 0 + } + hi := occEnd + claimedToolWindowBytes + if hi > len(lower) { + hi = len(lower) + } + window := lower[lo:hi] + for _, phrase := range claimedUnavailablePhrases { + if strings.Contains(window, phrase) { + return true + } + } + return false +} + +// endsWithNoPhrase reports whether the lowercased text ending at the tool +// name ends with "no " or "there is no " — i.e. the name was introduced by a +// durative negation ("There is no ToolSearch tool in my available toolset"). +func endsWithNoPhrase(lower string) bool { + pre := strings.TrimRight(lower, " \t") + return strings.HasSuffix(pre, "no") || strings.HasSuffix(pre, "there is no") +} + +// maybeReportClaimedToolUnavailable persists one source="auto" negative +// RouterFeedbackEvent per (session, role, tool) when the captured response +// text claims a declared tool is unavailable. Best-effort and off the +// response path: capture-gated (no respBody -> no-op), never influences +// routing, and hard stops on nil deps. +func (s *Service) maybeReportClaimedToolUnavailable( + ctx context.Context, + respBody []byte, + streaming bool, + availableTools []string, + installationID uuid.UUID, + sessionKey [sessionpin.SessionKeyLen]byte, + role string, + requestedModel string, + servedModel string, + requestID string, + routeID string, + clientID ClientIdentity, +) { + if s.feedbackStore == nil || s.claimedToolTracker == nil { + return + } + if installationID == uuid.Nil || len(respBody) == 0 || len(availableTools) == 0 { + return + } + found := claimedToolUnavailableFromBody(respBody, streaming, availableTools) + if len(found) == 0 { + return + } + log := observability.FromContext(ctx) + routerUserID := auth.UserIDFrom(ctx) + for _, tool := range found { + key := claimedToolFiredKey(sessionKey, role, tool) + if _, seen := s.claimedToolTracker.fired.Get(key); seen { + continue + } + log.Info("router.claimed_tool_unavailable", + "tool", tool, + "served_model", servedModel, + "requested_model", requestedModel, + "request_id", requestID, + "session_key_prefix", shortSessionKey(sessionKey), + "role", role, + ) + event := RouterFeedbackEvent{ + InstallationID: installationID.String(), + SessionKey: sessionKey[:], + Role: role, + RouterUserID: routerUserID, + ClientApp: clientID.ClientApp, + SessionID: clientID.SessionID, + RequestedModel: requestedModel, + ServedModel: servedModel, + Rating: "down", + Feedback: "claimed-tool-unavailable:" + tool, + Source: RouterFeedbackSourceAuto, + RequestID: requestID, + RouteID: routeID, + } + // context.Background(): the request ctx may already be canceled by the + // time this runs post-stream; losing the row would drop the failing + // turn from the auto corpus. + if err := s.feedbackStore.InsertRouterFeedback(context.Background(), event); err != nil { + log.Error("router.claimed_tool_unavailable: feedback insert failed", "err", err) + continue // leave the LRU unset so the next turn retries + } + s.claimedToolTracker.fired.Add(key, struct{}{}) + } +} diff --git a/internal/proxy/claimed_tool_unavailable_internal_test.go b/internal/proxy/claimed_tool_unavailable_internal_test.go new file mode 100644 index 000000000..56071a545 --- /dev/null +++ b/internal/proxy/claimed_tool_unavailable_internal_test.go @@ -0,0 +1,321 @@ +package proxy + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + + "workweave/router/internal/router/sessionpin" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// claimedToolFakeStore captures RouterFeedbackEvents for method-level tests; +// clusters, so the dedupe path (one insert per [session, role, tool]) is +// directly observable. err supplies insert failures on demand. +type claimedToolFakeStore struct { + mu sync.Mutex + events []RouterFeedbackEvent + err error +} + +func (f *claimedToolFakeStore) InsertRouterFeedback(ctx context.Context, p RouterFeedbackEvent) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.err != nil { + return f.err + } + f.events = append(f.events, p) + return nil +} + +func (f *claimedToolFakeStore) snap() []RouterFeedbackEvent { + f.mu.Lock() + defer f.mu.Unlock() + return append([]RouterFeedbackEvent(nil), f.events...) +} + +func claimedToolHost() (uuid.UUID, [sessionpin.SessionKeyLen]byte) { + return uuid.New(), sessionKeyFromString("claimed-tool-test") +} + +func claimedToolService(store RouterFeedbackStore) *Service { + if store == nil { + store = &claimedToolFakeStore{} + } + return &Service{ + feedbackStore: store, + claimedToolTracker: newClaimedToolTracker(), + } +} + +func TestDetectClaimedToolUnavailable_Positives(t *testing.T) { + cases := []struct { + name string + text string + tool string + }{ + {"no-tool-in-toolset", "There is no ToolSearch tool in my available toolset", "ToolSearch"}, + {"not-directly-callable", "EnterPlanMode is not directly callable, so I'll just edit the file", "EnterPlanMode"}, + {"dont-have-access", "I don't have access to the Read tool right now", "Read"}, + {"isn't-available", "The Bash tool isn't available in this environment", "Bash"}, + {"there-is-no-precede", "sorry, there is no WebSearch tool that I can call", "WebSearch"}, + {"not-exposed", "The Read tool is not exposed in this toolset", "Read"}, + {"tool-name-in-backticks", "the `WebSearch` tool is not callable here", "WebSearch"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + found := detectClaimedToolUnavailable(tc.text, []string{tc.tool}) + assert.Equal(t, []string{tc.tool}, found) + }) + } +} + +func TestDetectClaimedToolUnavailable_WindowBoundary(t *testing.T) { + // Pins both sides of the ±claimedToolWindowBytes window. With two filler + // sentences (~100 bytes) the claim phrase starts ~140 bytes after the name + // and is inside the window; with four (~200 bytes) it falls outside and + // must not fire. + filler := "I will run the search and summarize what I find. " + inside := `That's fine — ToolSearch for the workspace is active, ` + + strings.Repeat(filler, 2) + + `but it is not available from this agent, so I'll skip it` + assert.Equal(t, []string{"ToolSearch"}, detectClaimedToolUnavailable(inside, []string{"ToolSearch"})) + + outside := `That's fine — ToolSearch for the workspace is active, ` + + strings.Repeat(filler, 4) + + `but it is not available from this agent, so I'll skip it` + assert.Empty(t, detectClaimedToolUnavailable(outside, []string{"ToolSearch"})) +} + +func TestDetectClaimedToolUnavailable_Negatives(t *testing.T) { + cases := []struct { + name string + text string + tool string + }{ + {"tool-praised-normally", "I'll use ToolSearch to load the needed files", "ToolSearch"}, + {"name-as-substring", "MyToolSearchWrapper is not available in this build", "ToolSearch"}, + {"phrase-mismatched-case", "there is no toolsearch tool in my available toolset", "ToolSearch"}, + {"name-flanked-by-ass,no-claim", "ToolSearch_extra is fine", "ToolSearch"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + found := detectClaimedToolUnavailable(tc.text, []string{tc.tool}) + assert.Empty(t, found) + }) + } +} + +func TestDetectClaimedToolUnavailable_PhraseAboutUndeclaredTool(t *testing.T) { + // "Bash" is claimed unavailable, but only ToolSearch was declared — the + // scan matches declared names exclusively, so it must not fire. + found := detectClaimedToolUnavailable( + "I don't have access to the Bash tool here", []string{"ToolSearch"}) + assert.Empty(t, found) +} + +func TestDetectClaimedToolUnavailable_PhraseBeyondWindow(t *testing.T) { + // The name and the claim are separated by far more than 160 bytes. + padding := strings.Repeat("lorem ipsum dolor sit amet consectetur ", 60) + text := "ToolSearch can be tried via Bash. " + padding + "it is not available today." + found := detectClaimedToolUnavailable(text, []string{"ToolSearch"}) + assert.Empty(t, found) +} + +func TestDetectClaimedToolUnavailable_EmptyText(t *testing.T) { + assert.Empty(t, detectClaimedToolUnavailable("", []string{"ToolSearch"})) + assert.Empty(t, detectClaimedToolUnavailable("no claim here", nil)) +} + +func TestDetectClaimedToolUnavailable_DedupesAndCaps(t *testing.T) { + tools := []string{"Read", "Write", "WebSearch", "Bash", "Task"} + text := `I don't have access to Read, Write, WebSearch, Bash, or Task here.` + found := detectClaimedToolUnavailable(text, tools) + // All five were declared and (within 160 bytes of each name) claimed + // unavailable — but findings are deduped by name and capped at 4. + assert.Len(t, found, claimedToolMaxFindings) + seen := map[string]bool{} + for _, f := range found { + assert.False(t, seen[f], "duplicate finding %q", f) + seen[f] = true + } +} + +func TestClaimedToolUnavailableFromBody_SSEAcrossDeltas(t *testing.T) { + sse := strings.Join([]string{ + "event: message_start", + `data: {"type":"message_start","message":{"id":"msg_1","role":"assistant"}}`, + "", + "event: content_block_start", + `data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`, + "", + "event: content_block_delta", + `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"I'd like to use "}}`, + "", + "event: content_block_delta", + `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"the ToolSearch "}}`, + "", + "event: content_block_delta", + `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"tool, but there is "}}`, + "", + "event: content_block_delta", + `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"no such tool here."}}`, + "", + "event: content_block_delta", + `data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"shrug"}}`, + "", + "event: content_block_delta", + `data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{}"}}`, + "", + "event: content_block_stop", + `data: {"type":"content_block_stop","index":0}`, + "", + "event: message_stop", + `data: {"type":"message_stop"}`, + "", + }, "\n") + + found := claimedToolUnavailableFromBody([]byte(sse), true, []string{"ToolSearch"}) + // The claim is split across four text_delta frames; extraction + // concatenates them, so the phrase is still detected. + assert.Equal(t, []string{"ToolSearch"}, found) +} + +func TestClaimedToolUnavailableFromBody_SSEMalformedFailsOpen(t *testing.T) { + // Truncated mid-frame JSON and a bare data line (no space) must not error + // or panic, and must produce no findings. + bad := []byte("event: content_block_delta\ndata: {\"type\":\"content_") + require.NotPanics(t, func() { + assert.Empty(t, claimedToolUnavailableFromBody(bad, true, []string{"ToolSearch"})) + }) + assert.Empty(t, claimedToolUnavailableFromBody( + []byte("data:{\"type\":\"content_block_delta\"}\ndata: [DONE]"), true, []string{"ToolSearch"})) +} + +func TestClaimedToolUnavailableFromBody_NonStreaming(t *testing.T) { + body := `{ + "id": "msg_2", + "type": "message", + "role": "assistant", + "content": [ + {"type": "text", "text": "I would normally use this."}, + {"type": "text", "text": " The EnterPlanMode tool is not directly callable from my toolset."}, + {"type": "tool_use", "id": "toolu_1", "name": "Edit", "input": {}} + ] + }` + found := claimedToolUnavailableFromBody([]byte(body), false, []string{"EnterPlanMode", "Edit"}) + assert.Equal(t, []string{"EnterPlanMode"}, found) +} + +func TestClaimedToolUnavailableFromBody_NonStreamingMalformed(t *testing.T) { + assert.Empty(t, claimedToolUnavailableFromBody([]byte(`{"content": "notanarray"`), false, []string{"Read"})) + assert.Empty(t, claimedToolUnavailableFromBody([]byte(`{truncated`), false, []string{"Read"})) +} + +func TestMaybeReportClaimedToolUnavailable_InsertsOneEvent(t *testing.T) { + store := &claimedToolFakeStore{} + svc := claimedToolService(store) + installationID, sessionKey := claimedToolHost() + body := []byte(`{"content":[{"type":"text","text":"There is no ToolSearch tool in my available toolset."}]}`) + + svc.maybeReportClaimedToolUnavailable( + context.Background(), body, false, []string{"ToolSearch"}, + installationID, sessionKey, "default_high", "claude-sonnet-5", "qwen3", + "req-1", "route-1", ClientIdentity{ClientApp: ClientAppClaudeCode, SessionID: "sess-1"}, + ) + + events := store.snap() + require.Len(t, events, 1) + ev := events[0] + assert.Equal(t, installationID.String(), ev.InstallationID) + assert.Equal(t, []byte(sessionKey[:]), ev.SessionKey) + assert.Equal(t, "default_high", ev.Role) + assert.Equal(t, "claude-sonnet-5", ev.RequestedModel) + assert.Equal(t, "qwen3", ev.ServedModel) + assert.Equal(t, "down", ev.Rating) + assert.Equal(t, "claimed-tool-unavailable:ToolSearch", ev.Feedback) + assert.Equal(t, RouterFeedbackSourceAuto, ev.Source) + assert.Equal(t, "req-1", ev.RequestID) + assert.Equal(t, "route-1", ev.RouteID) + assert.Equal(t, ClientAppClaudeCode, ev.ClientApp) + assert.Equal(t, "sess-1", ev.SessionID) +} + +func TestMaybeReportClaimedToolUnavailable_DedupesSecondCall(t *testing.T) { + store := &claimedToolFakeStore{} + svc := claimedToolService(store) + installationID, sessionKey := claimedToolHost() + body := []byte(`{"content":[{"type":"text","text":"I don't have access to the Read tool."}]}`) + + svc.maybeReportClaimedToolUnavailable( + context.Background(), body, false, []string{"Read"}, + installationID, sessionKey, "default_high", "a", "b", "req-1", "route-1", ClientIdentity{}) + svc.maybeReportClaimedToolUnavailable( + context.Background(), body, false, []string{"Read"}, + installationID, sessionKey, "default_high", "a", "b", "req-2", "route-1", ClientIdentity{}) + + events := store.snap() + require.Len(t, events, 1, "same (session, role, tool) must fire once") + assert.Equal(t, "req-1", events[0].RequestID) +} + +func TestMaybeReportClaimedToolUnavailable_InsertErrorLeavesLRUUnset(t *testing.T) { + store := &claimedToolFakeStore{} + svc := claimedToolService(store) + installationID, sessionKey := claimedToolHost() + body := []byte(`{"content":[{"type":"text","text":"The Task tool is not callable here."}]}`) + + store.err = errors.New("db down") + svc.maybeReportClaimedToolUnavailable( + context.Background(), body, false, []string{"Task"}, + installationID, sessionKey, "default_high", "a", "b", "req-1", "route-1", ClientIdentity{}) + require.Empty(t, store.snap(), "failed insert must not persist a row") + + // Insert failure leaves the LRU unset, so a later turn retries and lands. + store.err = nil + svc.maybeReportClaimedToolUnavailable( + context.Background(), body, false, []string{"Task"}, + installationID, sessionKey, "default_high", "a", "b", "req-2", "route-1", ClientIdentity{}) + events := store.snap() + require.Len(t, events, 1) + assert.Equal(t, "claimed-tool-unavailable:Task", events[0].Feedback) +} + +func TestMaybeReportClaimedToolUnavailable_NilStoreShortCircuits(t *testing.T) { + svc := &Service{feedbackStore: nil, claimedToolTracker: newClaimedToolTracker()} + installationID, sessionKey := claimedToolHost() + // No store: must not panic, must produce no events. + require.NotPanics(t, func() { + svc.maybeReportClaimedToolUnavailable( + context.Background(), []byte(`{"content":[{"type":"text","text":"no Read tool!"}]}`), false, + []string{"Read"}, installationID, sessionKey, "default_high", "a", "b", + "req-1", "route-1", ClientIdentity{}) + }) +} + +func TestMaybeReportClaimedToolUnavailable_NilInstallationShortCircuits(t *testing.T) { + store := &claimedToolFakeStore{} + svc := claimedToolService(store) + _, sessionKey := claimedToolHost() + svc.maybeReportClaimedToolUnavailable( + context.Background(), []byte(`{"content":[{"type":"text","text":"Read is not available."}]}`), false, + []string{"Read"}, uuid.Nil, sessionKey, "default_high", "a", "b", + "req-1", "route-1", ClientIdentity{}) + assert.Empty(t, store.snap(), "uuid.Nil installation must not persist feedback") +} + +func TestMaybeReportClaimedToolUnavailable_NoAvailableToolsShortCircuits(t *testing.T) { + store := &claimedToolFakeStore{} + svc := claimedToolService(store) + installationID, sessionKey := claimedToolHost() + svc.maybeReportClaimedToolUnavailable( + context.Background(), []byte(`{"content":[{"type":"text","text":"Read is not available."}]}`), false, + nil, installationID, sessionKey, "default_high", "a", "b", + "req-1", "route-1", ClientIdentity{}) + assert.Empty(t, store.snap(), "no declared tools must not produce a finding") +} diff --git a/internal/proxy/feedback.go b/internal/proxy/feedback.go index 0dae6ba8e..574ba4e36 100644 --- a/internal/proxy/feedback.go +++ b/internal/proxy/feedback.go @@ -206,7 +206,10 @@ func (s *Service) feedbackFooter(clientApp string, tt turntype.TurnType) string if _, ok := terminalFeedbackClients[clientApp]; !ok { return "" } - if tt != turntype.MainLoop && tt != turntype.ToolResult { + // Base() so the harness variants (HarnessMeta/Recovery, whose underlying + // shape is MainLoop/ToolResult) still surface the footer; SubAgentHarnessMeta + // (-> SubAgentDispatch) stays excluded like any other sub-agent dispatch. + if tt.Base() != turntype.MainLoop && tt.Base() != turntype.ToolResult { return "" } return feedbackFooterText diff --git a/internal/proxy/harness_escalation.go b/internal/proxy/harness_escalation.go new file mode 100644 index 000000000..3b7937a96 --- /dev/null +++ b/internal/proxy/harness_escalation.go @@ -0,0 +1,170 @@ +// The harness-protocol escalation clamp is a deterministic per-turn guard that +// routes harness-bound turns UP, never down. Detected harness variants +// (HarnessMeta, SubAgentHarnessMeta, Recovery — see turntype.HarnessEscalation) +// operate the harness control plane (plan-mode tools, deferred-tool discovery, +// recovery from harness-shape failures); a non-Anthropic or weak upstream that +// hallucinates those primitives corrupts the client's harness state. So once +// the routing decision resolves, if the chosen model is not a strong +// Claude-family TierHigh model the decision is replaced with the escalation +// target. The clamp is per-TURN and non-persisted — it only rewrites the +// current decision; it never touches session pins, so the next turn routes +// through the normal pipeline again. +package proxy + +import ( + "context" + + "workweave/router/internal/observability" + "workweave/router/internal/providers" + "workweave/router/internal/router" + "workweave/router/internal/router/catalog" + "workweave/router/internal/translate" +) + +// Harness-escalation action taxonomy, recorded per clamp attempt. Exactly one +// applies. Mirrors the loop-escalation action-taxonomy comment style +// (loop_detection.go): each name records why the clamp did or did not engage. +const ( + // harnessActionEscalated: the decision was replaced with escalateModel. + harnessActionEscalated = "escalated" + // harnessActionAlreadyStrong: the resolved decision is already a strong + // Claude-family model — the clamp is a no-op. + harnessActionAlreadyStrong = "already_strong" + // harnessActionUsageBypass: the caller's subscription usage-bypass outranks + // the clamp; the requested model is served straight through. + harnessActionUsageBypass = "usage_bypass" + // harnessActionHardPinned: an operator/planner hard pin outranks the clamp; + // the pinned decision is left in place. + harnessActionHardPinned = "hard_pinned" + // harnessActionUserForced: a /force-model pin (or x-weave-force-model) + // outranks the clamp; the forced model is left in place. + harnessActionUserForced = "user_forced" + // harnessActionAlreadyEscalated: the decision was already replaced by the + // loop-escalation pin (an earlier, stronger escalation) — no further clamp. + harnessActionAlreadyEscalated = "already_escalated" + // harnessActionDisabled: the ROUTER_HARNESS_ESCALATION_ENABLED kill switch + // is off; the clamp does not engage. + harnessActionDisabled = "disabled" + // harnessActionProviderIneligible: anthropic is not in the request's + // enabled-provider set (or the set is empty of anthropic), so the escalate + // target is unservable; the original decision stands. + harnessActionProviderIneligible = "provider_ineligible" + // harnessActionModelExcluded: the request's excluded-models set blocks + // escalateModel; the original decision stands. + harnessActionModelExcluded = "model_excluded" +) + +// applyHarnessEscalation clamps a resolved harness-protocol turn decision to a +// strong Claude-family model (route up, never down). It runs exactly once per +// turn, after routing, as the single choke point on every decision path. +// +// The clamp is a deterministic decision rewrite and never errors: every branch +// either leaves res.Decision untouched (logging why) or replaces it with +// {anthropic, escalateModel}. Pins, PinTier, StickyHit and Fresh are never +// mutated — the next turn routes fresh. +func (s *Service) applyHarnessEscalation(ctx context.Context, res *turnLoopResult, req router.Request) { + if !res.TurnType.HarnessEscalation() { + return + } + log := observability.FromContext(ctx) + + // Precedence: a subscription usage-bypass, an operator/planner hard pin, and + // a user /force-model pin each outrank the clamp (the same three that + // outrank loop escalation). A loop-escalation decision already landed on + // opus — a stronger escalation than this clamp — so it needs no rewrite. + switch { + case res.UsageBypass: + log.Info("router.harness_escalation", + "action", harnessActionUsageBypass, + "turn_type", string(res.TurnType), + "from_model", res.Decision.Model, + "from_provider", res.Decision.Provider, + "to_model", escalateModel, + ) + return + case res.HardPinned: + log.Info("router.harness_escalation", + "action", harnessActionHardPinned, + "turn_type", string(res.TurnType), + "from_model", res.Decision.Model, + "from_provider", res.Decision.Provider, + "to_model", escalateModel, + ) + return + case isUserForcedReason(res.Decision.Reason): + log.Info("router.harness_escalation", + "action", harnessActionUserForced, + "turn_type", string(res.TurnType), + "from_model", res.Decision.Model, + "from_provider", res.Decision.Provider, + "to_model", escalateModel, + ) + return + case res.Decision.Reason == translate.ReasonLoopEscalation: + log.Info("router.harness_escalation", + "action", harnessActionAlreadyEscalated, + "turn_type", string(res.TurnType), + "from_model", res.Decision.Model, + "from_provider", res.Decision.Provider, + "to_model", escalateModel, + ) + return + case !s.harnessEscalationEnabled: + log.Info("router.harness_escalation", + "action", harnessActionDisabled, + "turn_type", string(res.TurnType), + "from_model", res.Decision.Model, + "from_provider", res.Decision.Provider, + "to_model", escalateModel, + ) + return + case catalog.IsClaudeFamily(res.Decision.Model) && catalog.TierFor(res.Decision.Model) >= catalog.TierHigh: + log.Info("router.harness_escalation", + "action", harnessActionAlreadyStrong, + "turn_type", string(res.TurnType), + "from_model", res.Decision.Model, + "from_provider", res.Decision.Provider, + "to_model", escalateModel, + ) + return + } + + // Eligible-provider / excluded-model guards: the escalate target must be + // servable for this request, or clamping would dead-end the turn. + if req.EnabledProviders != nil { + if _, ok := req.EnabledProviders[providers.ProviderAnthropic]; !ok { + log.Info("router.harness_escalation", + "action", harnessActionProviderIneligible, + "turn_type", string(res.TurnType), + "from_model", res.Decision.Model, + "from_provider", res.Decision.Provider, + "to_model", escalateModel, + ) + return + } + } + if _, ok := req.ExcludedModels[escalateModel]; ok { + log.Info("router.harness_escalation", + "action", harnessActionModelExcluded, + "turn_type", string(res.TurnType), + "from_model", res.Decision.Model, + "from_provider", res.Decision.Provider, + "to_model", escalateModel, + ) + return + } + + log.Info("router.harness_escalation", + "action", harnessActionEscalated, + "turn_type", string(res.TurnType), + "from_model", res.Decision.Model, + "from_provider", res.Decision.Provider, + "to_model", escalateModel, + ) + res.Decision = router.Decision{ + Provider: providers.ProviderAnthropic, + Model: escalateModel, + Reason: translate.ReasonHarnessEscalation, + } + res.HarnessEscalated = true +} diff --git a/internal/proxy/harness_escalation_internal_test.go b/internal/proxy/harness_escalation_internal_test.go new file mode 100644 index 000000000..52d909d94 --- /dev/null +++ b/internal/proxy/harness_escalation_internal_test.go @@ -0,0 +1,203 @@ +package proxy + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "workweave/router/internal/providers" + "workweave/router/internal/router" + "workweave/router/internal/router/turntype" + "workweave/router/internal/translate" +) + +// newHarnessEscalationSvc wires a Service with just the pieces +// applyHarnessEscalation touches. +func newHarnessEscalationSvc() *Service { + return NewService(nil, nil, nil, false, nil, newStubPinStore(), false, "anthropic", "claude-haiku-4-5", nil) +} + +func TestApplyHarnessEscalation_EscalatesLowTierNonClaudeOnEachHarnessTurnType(t *testing.T) { + for _, tt := range []turntype.TurnType{turntype.HarnessMeta, turntype.SubAgentHarnessMeta, turntype.Recovery} { + t.Run(string(tt), func(t *testing.T) { + svc := newHarnessEscalationSvc() + res := turnLoopResult{ + TurnType: tt, + Decision: router.Decision{Provider: providers.ProviderOpenAI, Model: "gpt-5-mini", Reason: "best_pick"}, + } + svc.applyHarnessEscalation(context.Background(), &res, router.Request{}) + + assert.Equal(t, escalateModel, res.Decision.Model) + assert.Equal(t, providers.ProviderAnthropic, res.Decision.Provider) + assert.Equal(t, translate.ReasonHarnessEscalation, res.Decision.Reason) + assert.True(t, res.HarnessEscalated, "clamp must flag the rewrite") + }) + } +} + +func TestApplyHarnessEscalation_NonHarnessTurnTypeUntouched(t *testing.T) { + for _, tt := range []turntype.TurnType{turntype.MainLoop, turntype.ToolResult, turntype.SubAgentDispatch, turntype.Compaction, turntype.Probe, turntype.TitleGen, turntype.Classifier} { + t.Run(string(tt), func(t *testing.T) { + svc := newHarnessEscalationSvc() + original := router.Decision{Provider: providers.ProviderOpenAI, Model: "gpt-5-mini", Reason: "best_pick"} + res := turnLoopResult{TurnType: tt, Decision: original} + svc.applyHarnessEscalation(context.Background(), &res, router.Request{}) + + assert.Equal(t, original, res.Decision, "clamp must not engage for a non-harness turn type") + assert.False(t, res.HarnessEscalated) + }) + } +} + +func TestApplyHarnessEscalation_AlreadyStrongClaudeTierHighUntouched(t *testing.T) { + svc := newHarnessEscalationSvc() + original := router.Decision{Provider: providers.ProviderAnthropic, Model: escalateModel, Reason: "best_pick"} + res := turnLoopResult{TurnType: turntype.HarnessMeta, Decision: original} + svc.applyHarnessEscalation(context.Background(), &res, router.Request{}) + + assert.Equal(t, original, res.Decision, "already on a strong Claude-family model -> no-op") + assert.False(t, res.HarnessEscalated) +} + +func TestApplyHarnessEscalation_ClaudeMidTierStillEscalates(t *testing.T) { + // claude-sonnet-4-6 is Claude-family but TierMid, not TierHigh — the clamp + // must still route up rather than treating any Claude model as strong enough. + svc := newHarnessEscalationSvc() + res := turnLoopResult{ + TurnType: turntype.HarnessMeta, + Decision: router.Decision{Provider: providers.ProviderAnthropic, Model: "claude-sonnet-4-6", Reason: "best_pick"}, + } + svc.applyHarnessEscalation(context.Background(), &res, router.Request{}) + + assert.Equal(t, escalateModel, res.Decision.Model, "Claude family but below TierHigh must still escalate") + assert.True(t, res.HarnessEscalated) +} + +func TestApplyHarnessEscalation_NonClaudeTierHighStillEscalates(t *testing.T) { + // gpt-5 is TierHigh but not Claude-family — the clamp cares about family, + // not just tier, since a non-Anthropic upstream can still hallucinate + // harness primitives. + svc := newHarnessEscalationSvc() + res := turnLoopResult{ + TurnType: turntype.HarnessMeta, + Decision: router.Decision{Provider: providers.ProviderOpenAI, Model: "gpt-5", Reason: "best_pick"}, + } + svc.applyHarnessEscalation(context.Background(), &res, router.Request{}) + + assert.Equal(t, escalateModel, res.Decision.Model, "TierHigh but non-Claude-family must still escalate") + assert.True(t, res.HarnessEscalated) +} + +func TestApplyHarnessEscalation_UsageBypassOutranksClamp(t *testing.T) { + svc := newHarnessEscalationSvc() + original := router.Decision{Provider: providers.ProviderOpenAI, Model: "gpt-5-mini", Reason: "best_pick"} + res := turnLoopResult{TurnType: turntype.HarnessMeta, Decision: original, UsageBypass: true} + svc.applyHarnessEscalation(context.Background(), &res, router.Request{}) + + assert.Equal(t, original, res.Decision, "subscription usage-bypass outranks the clamp") + assert.False(t, res.HarnessEscalated) +} + +func TestApplyHarnessEscalation_HardPinnedOutranksClamp(t *testing.T) { + svc := newHarnessEscalationSvc() + original := router.Decision{Provider: providers.ProviderOpenAI, Model: "gpt-5-mini", Reason: "best_pick"} + res := turnLoopResult{TurnType: turntype.HarnessMeta, Decision: original, HardPinned: true} + svc.applyHarnessEscalation(context.Background(), &res, router.Request{}) + + assert.Equal(t, original, res.Decision, "an operator/planner hard pin outranks the clamp") + assert.False(t, res.HarnessEscalated) +} + +func TestApplyHarnessEscalation_UserForcedOutranksClamp(t *testing.T) { + svc := newHarnessEscalationSvc() + original := router.Decision{Provider: providers.ProviderOpenAI, Model: "gpt-5-mini", Reason: translate.ReasonUserForceModel} + res := turnLoopResult{TurnType: turntype.HarnessMeta, Decision: original} + svc.applyHarnessEscalation(context.Background(), &res, router.Request{}) + + assert.Equal(t, original, res.Decision, "a /force-model pin outranks the clamp") + assert.False(t, res.HarnessEscalated) +} + +func TestApplyHarnessEscalation_AlreadyLoopEscalatedOutranksClamp(t *testing.T) { + svc := newHarnessEscalationSvc() + original := router.Decision{Provider: providers.ProviderAnthropic, Model: escalateModel, Reason: translate.ReasonLoopEscalation} + res := turnLoopResult{TurnType: turntype.Recovery, Decision: original} + svc.applyHarnessEscalation(context.Background(), &res, router.Request{}) + + assert.Equal(t, original, res.Decision, "an existing loop-escalation decision is already a stronger rescue") + assert.False(t, res.HarnessEscalated) +} + +func TestApplyHarnessEscalation_KillSwitchDisablesClamp(t *testing.T) { + svc := newHarnessEscalationSvc().WithHarnessEscalationConfig(false) + original := router.Decision{Provider: providers.ProviderOpenAI, Model: "gpt-5-mini", Reason: "best_pick"} + res := turnLoopResult{TurnType: turntype.HarnessMeta, Decision: original} + svc.applyHarnessEscalation(context.Background(), &res, router.Request{}) + + assert.Equal(t, original, res.Decision, "ROUTER_HARNESS_ESCALATION_ENABLED=false must suppress the clamp") + assert.False(t, res.HarnessEscalated) +} + +func TestApplyHarnessEscalation_ProviderIneligibleLeavesDecisionUntouched(t *testing.T) { + svc := newHarnessEscalationSvc() + original := router.Decision{Provider: providers.ProviderOpenAI, Model: "gpt-5-mini", Reason: "best_pick"} + res := turnLoopResult{TurnType: turntype.HarnessMeta, Decision: original} + req := router.Request{EnabledProviders: map[string]struct{}{providers.ProviderOpenAI: {}}} + svc.applyHarnessEscalation(context.Background(), &res, req) + + assert.Equal(t, original, res.Decision, "anthropic not in EnabledProviders -> escalate target unservable") + assert.False(t, res.HarnessEscalated) +} + +func TestApplyHarnessEscalation_ModelExcludedLeavesDecisionUntouched(t *testing.T) { + svc := newHarnessEscalationSvc() + original := router.Decision{Provider: providers.ProviderOpenAI, Model: "gpt-5-mini", Reason: "best_pick"} + res := turnLoopResult{TurnType: turntype.HarnessMeta, Decision: original} + req := router.Request{ExcludedModels: map[string]struct{}{escalateModel: {}}} + svc.applyHarnessEscalation(context.Background(), &res, req) + + assert.Equal(t, original, res.Decision, "escalateModel excluded -> original decision stands") + assert.False(t, res.HarnessEscalated) +} + +func TestApplyHarnessEscalation_NilEnabledProvidersIsUnrestricted(t *testing.T) { + // A nil EnabledProviders map means "no restriction" per router.Request + // semantics — the clamp must not treat nil as "anthropic disabled". + svc := newHarnessEscalationSvc() + res := turnLoopResult{ + TurnType: turntype.HarnessMeta, + Decision: router.Decision{Provider: providers.ProviderOpenAI, Model: "gpt-5-mini", Reason: "best_pick"}, + } + svc.applyHarnessEscalation(context.Background(), &res, router.Request{EnabledProviders: nil}) + + assert.Equal(t, escalateModel, res.Decision.Model, "nil EnabledProviders must not block escalation") + assert.True(t, res.HarnessEscalated) +} + +// TestIsHardPinnedTurn_SubAgentHarnessMetaNeverHardPinned guards the +// isHardPinnedTurn branch added alongside the clamp: even with the legacy +// hardPinExplore switch AND a sub-agent override both configured, +// SubAgentHarnessMeta must never take the hard-pin short-circuit — it needs +// to reach the scorer/planner so applyHarnessEscalation (not a hard pin) can +// decide the model. +func TestIsHardPinnedTurn_SubAgentHarnessMetaNeverHardPinned(t *testing.T) { + svc := NewService(nil, nil, nil, false, nil, newStubPinStore(), true, "anthropic", "claude-haiku-4-5", nil). + WithSubAgentOverride("anthropic", "claude-haiku-4-5") + + require.True(t, svc.hasSubAgentOverride()) + assert.False(t, svc.isHardPinnedTurn(context.Background(), turntype.SubAgentHarnessMeta)) + // Sanity: plain SubAgentDispatch DOES hard-pin under this same config, so + // the exemption above is specific to the harness variant, not a config bug. + assert.True(t, svc.isHardPinnedTurn(context.Background(), turntype.SubAgentDispatch)) +} + +func TestAuthoritativePolicyTurn_HarnessVariantsMatchUnderlyingShape(t *testing.T) { + assert.Equal(t, authoritativePolicyTurn(turntype.MainLoop), authoritativePolicyTurn(turntype.HarnessMeta), + "HarnessMeta must match MainLoop's authoritative-policy behavior") + assert.Equal(t, authoritativePolicyTurn(turntype.ToolResult), authoritativePolicyTurn(turntype.Recovery), + "Recovery must match ToolResult's authoritative-policy behavior") + assert.False(t, authoritativePolicyTurn(turntype.SubAgentHarnessMeta), + "SubAgentHarnessMeta's base (SubAgentDispatch) is not an authoritative-policy turn") +} diff --git a/internal/proxy/service.go b/internal/proxy/service.go index a57d73895..4c230b427 100644 --- a/internal/proxy/service.go +++ b/internal/proxy/service.go @@ -176,6 +176,12 @@ type Service struct { // escalate-to-opus action. False keeps detection/telemetry running // (action=disabled) but writes no escalation pin. Defaults true. loopEscalationEnabled bool + // harnessEscalationEnabled is the kill switch for the harness-protocol + // escalate clamp (route harness-bound turns up, never down). False keeps + // the clamp from engaging (action=disabled) while harness turn-type + // detection and routing continue unchanged. Defaults true; knob is + // ROUTER_HARNESS_ESCALATION_ENABLED. + harnessEscalationEnabled bool // loopEscalationHoldoutPct is the percentage of loop-detected sessions // deterministically assigned to a log-not-act holdout, so the self-recovery // baseline can be subtracted from rescue-rate claims. 0 disables it. @@ -195,6 +201,9 @@ type Service struct { // spiralTracker de-duplicates shadow fires per (session, role, reason) on // this replica. spiralTracker *spiralTracker + // claimedToolTracker de-duplicates claimed-tool-unavailable auto-feedback + // per (session, role, tool) on this replica (see claimed_tool_unavailable.go). + claimedToolTracker *claimedToolTracker // spiralShadowStore persists shadow spiral detections durably // (router.spiral_shadow_events) and enforces the once-per-(session, // reason) budget. Nil degrades to log-only fires. @@ -522,13 +531,14 @@ func sanitizeSidecarDisplayMarker(raw string) string { // the marker wording; tests assert the mapping against these constants rather // than re-spelling the literals. const ( - markerReasonUserForced = "pinned by force-model" - markerReasonLoopEscalated = "escalated due to loop" - markerReasonSwitched = "switched for positive EV after cache eviction" - markerReasonStayed = "stayed on your last pick" - markerReasonTierUpgrade = "upgraded to a stronger tier" - markerReasonBestPick = "best pick for this turn" - markerReasonBaseline = "fell back to baseline after provider outage" + markerReasonUserForced = "pinned by force-model" + markerReasonLoopEscalated = "escalated due to loop" + markerReasonHarnessEscalated = "escalated for harness protocol" + markerReasonSwitched = "switched for positive EV after cache eviction" + markerReasonStayed = "stayed on your last pick" + markerReasonTierUpgrade = "upgraded to a stronger tier" + markerReasonBestPick = "best pick for this turn" + markerReasonBaseline = "fell back to baseline after provider outage" ) // baselineRoutingMarkerFor renders the routing badge for an in-turn baseline @@ -558,6 +568,8 @@ func routingReasonShort(res turnLoopResult) string { return markerReasonUserForced case translate.ReasonLoopEscalation: return markerReasonLoopEscalated + case translate.ReasonHarnessEscalation: + return markerReasonHarnessEscalated } return markerReasonBestPick } @@ -1067,6 +1079,7 @@ func NewService(r router.Router, providerMap map[string]providers.Client, emitte plannerEnabled: true, scoreToolResultTurns: true, loopEscalationEnabled: true, + harnessEscalationEnabled: true, cyberRefusalRepin: false, cyberRefusalFallbackModel: "claude-sonnet-5", } @@ -1226,6 +1239,14 @@ func (s *Service) WithLoopEscalationConfig(enabled bool, holdoutPct int) *Servic return s } +// WithHarnessEscalationConfig sets the harness-protocol escalation clamp +// kill switch. enabled=false keeps the clamp from engaging (action=disabled) +// while harness turn-type detection and routing continue unchanged. +func (s *Service) WithHarnessEscalationConfig(enabled bool) *Service { + s.harnessEscalationEnabled = enabled + return s +} + // WithLoopEscalationStore wires the durable sink for loop-escalation events // (router.loop_escalation_events). Nil disables persistence, the holdout, and // the cross-TTL once-per-session budget (the pin-reason check still applies). @@ -2505,7 +2526,7 @@ func (s *Service) ProxyMessages(ctx context.Context, body []byte, w http.Respons // Text-repetition break: fresh tool calls each turn defeat the no-progress // fingerprint; repeated narration is the durable tell. See text_repetition.go. - if !agentShadowMode && !routeRes.AuthoritativePerTurn && s.textRepetitionBreakEnabled && (tt == turntype.MainLoop || tt == turntype.ToolResult) { + if !agentShadowMode && !routeRes.AuthoritativePerTurn && s.textRepetitionBreakEnabled && (tt.Base() == turntype.MainLoop || tt.Base() == turntype.ToolResult) { if looped, count, sampleHash := detectTextRepetition(env); looped { role := roleForTier(catalog.TierFor(feats.Model)) return s.handleTextRepetitionBreak(ctx, w, env, count, sampleHash, installationID, routeRes.SessionKey, role, decision.Model, decision.Provider, feats.Tokens) @@ -2517,7 +2538,7 @@ func (s *Service) ProxyMessages(ctx context.Context, body []byte, w http.Respons // reason), so fire rates/precision can be measured before any escalation // is armed. Main-loop / tool-result turns only — hard-pinned turn types // carry history shapes that mimic the signals. - if !agentShadowMode && s.spiralShadowEnabled && (tt == turntype.MainLoop || tt == turntype.ToolResult) { + if !agentShadowMode && s.spiralShadowEnabled && (tt.Base() == turntype.MainLoop || tt.Base() == turntype.ToolResult) { if reasons := spiralReasons(inboundSpiralSignals); len(reasons) > 0 { role := roleForTier(catalog.TierFor(feats.Model)) // Use the bindRequestLogger digest, not routeRes.SessionKey (zero @@ -3202,6 +3223,10 @@ func (s *Service) ProxyMessages(ctx context.Context, body []byte, w http.Respons if !agentShadowMode { s.recordCallLog(ctx, upstreamBuilder.Build(), proxyErr != nil, body, respBody, respTrunc) } + // Eval rows must not enter the auto-feedback corpus either. + if !agentShadowMode { + s.maybeReportClaimedToolUnavailable(ctx, respBody, env.Stream(), env.AvailableToolNames(), installationID, sessionKey, routeRes.PinRole, feats.Model, decision.Model, requestID, obs.RouteID, clientID) + } otel.Flush(ctx) if !agentShadowMode { @@ -3756,6 +3781,9 @@ func (s *Service) policyDeadlineDefaultDecision(req router.Request) (router.Deci // stays anchored so the pair survives for the next turn's swap. func (s *Service) bandSwapServed(ctx context.Context, turnType turntype.TurnType, pin sessionpin.Pin, fresh router.Decision, hasImages bool, enabledProviders, excludedModels map[string]struct{}) router.Decision { anchor := pinDecision(pin) + // turnType intentionally compared raw (not Base()): a harness-variant turn + // (Recovery is the tool-result shape) skips band swap and serves the anchor, + // leaving the harness escalation clamp to decide the model for that turn. if s.bandSwap == nil || pin.PairedModel == "" || turnType != turntype.MainLoop { return anchor } @@ -4224,7 +4252,7 @@ func int64PtrIf(known bool, v int64) *int64 { // history, so a trailing assistant reply after a prior tool_result would // otherwise write a stale non-NULL value. func toolResultBytesPtr(inbound translate.LastUserMessageInfo, tt turntype.TurnType) *int32 { - if tt != turntype.ToolResult || !inbound.HasToolResult { + if tt.Base() != turntype.ToolResult || !inbound.HasToolResult { return nil } v := int32(inbound.ToolResultBytes) diff --git a/internal/proxy/turnloop.go b/internal/proxy/turnloop.go index 83c3be57f..5d10e20a8 100644 --- a/internal/proxy/turnloop.go +++ b/internal/proxy/turnloop.go @@ -157,6 +157,10 @@ type turnLoopResult struct { // exhaustion. Stashed on ctx so resolveBindingsForDispatch's failover // walk also honors the exclusion, not just this turn's scorer. SessionDisabledProviders []string + // HarnessEscalated is true when the harness-protocol escalation clamp + // replaced this turn's decision (see applyHarnessEscalation). Per-turn + // only — never persisted to a session pin. + HarnessEscalated bool } // modelSwitched reports whether the Anthropic emit path must strip historical @@ -296,13 +300,23 @@ func (s *Service) isHardPinnedTurn(ctx context.Context, tt turntype.TurnType) bo return false } return s.hardPinExplore || s.hasSubAgentOverride() + case turntype.SubAgentHarnessMeta: + // Never hard-pinned: the harness escalation clamp routes sub-agent + // harness turns UP (its job), overriding any low-tier background pin a + // hard pin here would impose. + return false default: return false } } +// authoritativePolicyTurn reports whether tt is a model-authoritative policy +// turn. Compared on tt.Base() so the harness variants (HarnessMeta → +// MainLoop, Recovery → ToolResult) keep the authoritative-policy behavior of +// their underlying shape, while SubAgentHarnessMeta (→ SubAgentDispatch) stays +// out. func authoritativePolicyTurn(tt turntype.TurnType) bool { - return tt == turntype.MainLoop || tt == turntype.ToolResult + return tt.Base() == turntype.MainLoop || tt.Base() == turntype.ToolResult } func isUserForcedReason(reason string) bool { @@ -348,13 +362,35 @@ func forcedPinEligible(pin sessionpin.Pin, req router.Request) bool { return ok } -// runTurnLoop is the format-agnostic routing orchestrator: detect turn type, +// runTurnLoop is the format-agnostic routing orchestrator. It is the single +// choke point every decision path passes through exactly once: after the +// routing decision resolves it applies the harness-protocol escalation clamp +// (route harness-bound turns up, never down), then returns the result. +func (s *Service) runTurnLoop( + ctx context.Context, + env *translate.RequestEnvelope, + feats translate.RoutingFeatures, + apiKeyID string, + installationID uuid.UUID, + subAgentHint string, + reqHeaders http.Header, + req router.Request, +) (turnLoopResult, error) { + res, err := s.runTurnLoopInner(ctx, env, feats, apiKeyID, installationID, subAgentHint, reqHeaders, req) + if err != nil { + return turnLoopResult{}, err + } + s.applyHarnessEscalation(ctx, &res, req) + return res, nil +} + +// runTurnLoopInner is the body of the routing orchestrator: detect turn type, // short-circuit hard pins, load pin, run scorer, hand to planner, and on // switch attempt bounded-cost handover. // // installationID == uuid.Nil skips the async pin upsert (rows need one); the // rest of the path runs normally. -func (s *Service) runTurnLoop( +func (s *Service) runTurnLoopInner( ctx context.Context, env *translate.RequestEnvelope, feats translate.RoutingFeatures, @@ -460,6 +496,8 @@ func (s *Service) runTurnLoop( provider, model := s.hardPinProvider, s.hardPinModel // Sub-agent override is explicit operator config (mirrors ROUTER_HARD_PIN_MODEL // semantics), so it skips hardPinResolver rather than being resolved dynamically. + // SubAgentHarnessMeta never reaches here: isHardPinnedTurn returns false for + // it, so the harness escalation clamp (not a hard pin) routes it up. useSubAgentOverride := res.TurnType == turntype.SubAgentDispatch && s.hasSubAgentOverride() if useSubAgentOverride { provider, model = s.subAgentProvider, s.subAgentModel @@ -826,7 +864,7 @@ func (s *Service) runTurnLoop( // Switches degrade safely — handover.RewriteEnvelope strips orphaned tool_results. if !res.AuthoritativePerTurn && !s.scoreToolResultTurns && - res.TurnType == turntype.ToolResult && + res.TurnType.Base() == turntype.ToolResult && pinFound { decision := pinDecision(pin) res.Decision = decision @@ -1427,9 +1465,12 @@ func buildPolicyTurnContext( previousProvider = previous.Provider } return &router.PolicyTurnContext{ - VisibleTurnIndex: visibleTurnIndex, - SessionTurnCount: sessionTurnCount, - TurnType: string(res.TurnType), + VisibleTurnIndex: visibleTurnIndex, + SessionTurnCount: sessionTurnCount, + // Base() keeps the policy sidecar's turn-type vocabulary stable: the + // harness variants report their underlying shape so a deployed roster + // does not need to re-publish when a new harness detection lands. + TurnType: string(res.TurnType.Base()), PreviousServedModel: res.PriorServedModel, PreviousProvider: previousProvider, CacheState: cacheState, diff --git a/internal/router/catalog/family.go b/internal/router/catalog/family.go index 86adc567b..53fdca1f2 100644 --- a/internal/router/catalog/family.go +++ b/internal/router/catalog/family.go @@ -13,6 +13,18 @@ import ( // number (gpt-4o) don't match and are treated as singleton families. var familyVersionPattern = regexp.MustCompile(`^(.+?)(\d+)(?:[.-](\d+))?(-[a-z][a-z0-9\-]*)?$`) +// IsClaudeFamily reports whether id's family is a Claude family (family name +// starts with "claude"). Unknown/ungenerational ids (gpt-4o, "") are false. +// Used by the harness-protocol escalation clamp to test whether a resolved +// decision already sits on a strong Claude-family model. +func IsClaudeFamily(id string) bool { + family, _, ok := FamilyAndVersion(id) + if !ok { + return false + } + return strings.HasPrefix(family, "claude") +} + // FamilyAndVersion parses id into a family key (generation stripped, suffix // kept, e.g. "gpt-5.4-mini" → family "gpt-mini") and a (major, minor) version // tuple. ok=false means id has no generation number; treat as singleton family. diff --git a/internal/router/catalog/family_test.go b/internal/router/catalog/family_test.go index a0b6b9f4e..4164212d5 100644 --- a/internal/router/catalog/family_test.go +++ b/internal/router/catalog/family_test.go @@ -67,6 +67,29 @@ func TestFamilyAndVersion(t *testing.T) { } } +func TestIsClaudeFamily(t *testing.T) { + tests := []struct { + id string + want bool + }{ + {"claude-opus-5", true}, + {"claude-fable-5", true}, + {"claude-haiku-4-5", true}, + {"claude-opus-4-6", true}, + {"gpt-5", false}, + {"qwen/qwen3.8-max", false}, + {"deepseek/deepseek-v4-flash", false}, + {"", false}, + } + for _, tt := range tests { + t.Run(tt.id, func(t *testing.T) { + if got := IsClaudeFamily(tt.id); got != tt.want { + t.Errorf("IsClaudeFamily(%q) = %v, want %v", tt.id, got, tt.want) + } + }) + } +} + func TestFamilyDuplicates(t *testing.T) { ids := []string{ "claude-haiku-4-5", diff --git a/internal/router/turntype/AGENTS.md b/internal/router/turntype/AGENTS.md index b46ec70c2..36a0d51f9 100644 --- a/internal/router/turntype/AGENTS.md +++ b/internal/router/turntype/AGENTS.md @@ -15,6 +15,11 @@ Classifies inbound requests into: - `Probe` — proxy bypasses routing entirely - `TitleGen` — Claude Code sidebar-title generation; hard-pinned AND skips session-pin creation (an anchored pin here would leak the cheap-model decision into the real conversation that follows ~25ms later) - `Classifier` — short-form classification call (e.g. Claude Code's security monitor); hard-pinned AND skips session-pin creation +- `HarnessMeta` — main-turn skill/command invocation referencing harness primitives; proxy clamps these to a strong Claude-family model (route up, never down); detection is deliberately narrow (command markers + harness keyword gate) +- `SubAgentHarnessMeta` — sub-agent dispatch whose prompt references harness primitives; same clamp as `HarnessMeta` (route up, never down); detection is deliberately narrow (harness keyword gate over a bounded prompt prefix) +- `Recovery` — tool-result turn recovering from a deferred-tool/`InputValidationError` failure; same clamp (route up, never down); detection is deliberately narrow (`InputValidationError` + deferred-tool context) + +`TurnType.Base()` maps the three harness variants back to their underlying shape (`MainLoop` / `SubAgentDispatch` / `ToolResult`) for call sites that must stay behavior-compatible with the pre-harness vocabulary — notably the policy sidecar, whose turn-type labels must remain stable. `TurnType.HarnessEscalation()` reports whether the proxy's escalation clamp applies. Used by [`../../proxy`](../../proxy) to keep the action loop cheap + correct. diff --git a/internal/router/turntype/CLAUDE.md b/internal/router/turntype/CLAUDE.md index 24e75beed..bc3ea57f8 100644 --- a/internal/router/turntype/CLAUDE.md +++ b/internal/router/turntype/CLAUDE.md @@ -15,6 +15,11 @@ Classifies inbound requests into: - `Probe` — proxy bypasses routing entirely - `TitleGen` — Claude Code sidebar-title generation; hard-pinned AND skips session-pin creation (an anchored pin here would leak the cheap-model decision into the real conversation that follows ~25ms later) - `Classifier` — short-form classification call (e.g. Claude Code's security monitor); hard-pinned AND skips session-pin creation +- `HarnessMeta` — main-turn skill/command invocation referencing harness primitives; proxy clamps these to a strong Claude-family model (route up, never down); detection is deliberately narrow (command markers + harness keyword gate) +- `SubAgentHarnessMeta` — sub-agent dispatch whose prompt references harness primitives; same clamp as `HarnessMeta` (route up, never down); detection is deliberately narrow (harness keyword gate over a bounded prompt prefix) +- `Recovery` — tool-result turn recovering from a deferred-tool/`InputValidationError` failure; same clamp (route up, never down); detection is deliberately narrow (`InputValidationError` + deferred-tool context) + +`TurnType.Base()` maps the three harness variants back to their underlying shape (`MainLoop` / `SubAgentDispatch` / `ToolResult`) for call sites that must stay behavior-compatible with the pre-harness vocabulary — notably the policy sidecar, whose turn-type labels must remain stable. `TurnType.HarnessEscalation()` reports whether the proxy's escalation clamp applies. Used by [`../../proxy`](../../proxy) to keep the action loop cheap + correct. diff --git a/internal/router/turntype/detect.go b/internal/router/turntype/detect.go index 9852b6b88..be5028ae2 100644 --- a/internal/router/turntype/detect.go +++ b/internal/router/turntype/detect.go @@ -26,6 +26,23 @@ const ( // Classifier: short-form classification call (security monitor, etc.). // Hard-pinned AND skips session-pin creation. Classifier TurnType = "classifier" + // HarnessMeta: main-turn skill/command invocation referencing harness + // primitives (plan-mode tools, deferred-tool discovery, etc.). The proxy + // clamps these to a strong Claude-family model so the harness control plane + // cannot be silently downgraded to a non-Anthropic upstream that + // hallucinates harness primitives. + HarnessMeta TurnType = "harness_meta" + // SubAgentHarnessMeta: sub-agent dispatch whose dispatch prompt references + // harness primitives. Same clamp as HarnessMeta, scoped to sub-agent turns + // so a parent conversation routing up does not accidentally leak the + // hard-pin into the orchestrated sub-agent path. + SubAgentHarnessMeta TurnType = "sub_agent_harness_meta" + // Recovery: tool-result turn recovering from a deferred-tool + // InputValidationError. The previous turn's tool call failed for a + // harness-shape reason rather than an ordinary schema mistake; routing up + // here both retries against a model that knows the deferred-tool protocol + // and prevents a thrash loop against a weak upstream. + Recovery TurnType = "recovery" ) const probeMaxTokensThreshold = 4 @@ -61,11 +78,26 @@ func DetectFromEnvelope(env *translate.RequestEnvelope, feats translate.RoutingF return Compaction } if isSubAgentDispatch(env.MetadataUserID(), env.FirstUserMessageText(), subAgentHint) { + if isHarnessMetaSubAgent(env.FirstUserMessageText()) { + return SubAgentHarnessMeta + } return SubAgentDispatch } if isClassifier(feats) { return Classifier } + // Recovery before harness-meta: both look at the last user message, but a + // tool_result turn must NEVER classify as HarnessMeta — its underlying + // shape is a continuation, not a skill invocation, so the only escalation + // it can earn is Recovery. The explicit LastKind guard below enforces that + // even when a tool_result turn's text happens to contain + // command markers. + if env.SourceFormat() == translate.FormatAnthropic && isRecoveryTurn(env, feats) { + return Recovery + } + if env.SourceFormat() == translate.FormatAnthropic && feats.LastKind != "tool_result" && isHarnessMetaMainTurn(env) { + return HarnessMeta + } if feats.LastKind == "tool_result" { return ToolResult } @@ -130,3 +162,35 @@ func isSubAgentDispatch(metadataUserID, firstUserText, subAgentHint string) bool } return strings.Contains(prefix, "") } + +// Base returns the underlying turn shape a harness variant should be treated +// as at call sites that must keep behavior-compatible with the pre-harness +// vocabulary (the policy sidecar in particular: its turn-type labels must +// stay stable so a deployed roster does not need to re-publish when a new +// harness detection lands). Maps HarnessMeta → MainLoop, +// SubAgentHarnessMeta → SubAgentDispatch, Recovery → ToolResult; identity +// for every other value. +func (t TurnType) Base() TurnType { + switch t { + case HarnessMeta: + return MainLoop + case SubAgentHarnessMeta: + return SubAgentDispatch + case Recovery: + return ToolResult + default: + return t + } +} + +// HarnessEscalation reports whether the proxy's escalation clamp should +// treat the turn as harness-bound (route up, never down). True for exactly +// the three new harness-variant turn types; every existing value is false. +func (t TurnType) HarnessEscalation() bool { + switch t { + case HarnessMeta, SubAgentHarnessMeta, Recovery: + return true + default: + return false + } +} diff --git a/internal/router/turntype/detect_test.go b/internal/router/turntype/detect_test.go index 52f1e2ba7..22d5967e1 100644 --- a/internal/router/turntype/detect_test.go +++ b/internal/router/turntype/detect_test.go @@ -260,6 +260,88 @@ func TestDetectFromEnvelope_Anthropic(t *testing.T) { body: `{"model":"claude-opus-4-7","max_tokens":64,"metadata":{"user_id":"subagent:Explore"},"messages":[{"role":"user","content":"grep"}]}`, want: turntype.SubAgentDispatch, }, + { + // The real failing fixture: a sub-agent dispatched to load a + // deferred tool's schema was routed to a weak model that cannot + // honor the deferred-tool protocol. Detected via header hint. + name: "sub-agent dispatch asking for a deferred tool schema is sub_agent_harness_meta", + body: `{"model":"claude-haiku-4-5","tools":[{"name":"ToolSearch","input_schema":{"type":"object"}}],"messages":[{"role":"user","content":"Load EnterPlanMode tool schema"}]}`, + hint: "general-purpose", + want: turntype.SubAgentHarnessMeta, + }, + { + // Same fixture reached via the signal instead of the + // header — both sub-agent detection paths must escalate. + name: "sub-agent via asking for a deferred tool schema is sub_agent_harness_meta", + body: `{"model":"claude-opus-4-7","messages":[{"role":"user","content":"\nUser: Load EnterPlanMode tool schema\n"}]}`, + want: turntype.SubAgentHarnessMeta, + }, + { + // Regression guard: an ordinary code-search dispatch must stay on + // the cheap sub-agent path. Escalating every sub-agent would undo + // the whole point of sub-agent routing. + name: "sub-agent with a plain coding prompt stays sub_agent_dispatch", + body: `{"model":"claude-haiku-4-5","messages":[{"role":"user","content":"Find all callers of foo in pkg/bar"}]}`, + hint: "Explore", + want: turntype.SubAgentDispatch, + }, + { + // Claude Code emits / verbatim for + // slash + skill invocations; paired with a harness reference this + // is a control-plane turn. + name: "slash command invoking plan mode is harness_meta", + body: `{"model":"claude-opus-4-7","tools":[{"name":"Bash","input_schema":{"type":"object"}}],"messages":[{"role":"user","content":"qplan/qplan\nPlan the change, then call ExitPlanMode to leave plan mode."}]}`, + want: turntype.HarnessMeta, + }, + { + // Half the AND: command markers alone must not escalate, or every + // slash command in the harness would route up. + name: "slash command with no harness reference stays main_loop", + body: `{"model":"claude-opus-4-7","tools":[{"name":"Bash","input_schema":{"type":"object"}}],"messages":[{"role":"user","content":"/standup\nSummarize what I shipped yesterday."}]}`, + want: turntype.MainLoop, + }, + { + // Other half of the AND: prose that merely discusses a harness + // tool is not an invocation of it. + name: "prose mentioning ToolSearch without command markers stays main_loop", + body: `{"model":"claude-opus-4-7","tools":[{"name":"Bash","input_schema":{"type":"object"}}],"messages":[{"role":"user","content":"Explain how ToolSearch works and whether deferred tool loading is worth it."}]}`, + want: turntype.MainLoop, + }, + { + // Errored tool_result naming a harness primitive: the previous + // turn's deferred-tool call failed, so retry on a strong model. + name: "errored InputValidationError about a deferred tool is recovery", + body: `{"model":"claude-sonnet-4-5","tools":[{"name":"Bash","input_schema":{"type":"object"}}],"messages":[ + {"role":"user","content":"enter plan mode"}, + {"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"EnterPlanMode","input":{}}]}, + {"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","is_error":true,"content":"InputValidationError: tool EnterPlanMode requires its schema to be fetched first"}]} + ]}`, + want: turntype.Recovery, + }, + { + // Regression guard: an ordinary parameter-type mistake is a normal + // retry, not a harness failure. Escalating these would route up on + // every sloppy tool call. + name: "errored InputValidationError about an ordinary schema mistake stays tool_result", + body: `{"model":"claude-sonnet-4-5","tools":[{"name":"Bash","input_schema":{"type":"object"}}],"messages":[ + {"role":"user","content":"run it"}, + {"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{}}]}, + {"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","is_error":true,"content":"InputValidationError: command must be a string, got number"}]} + ]}`, + want: turntype.ToolResult, + }, + { + // Regression guard: is_error is load-bearing. A SUCCESSFUL tool + // result that happens to print the phrase (e.g. grepping this very + // test file) must not escalate. + name: "successful tool_result mentioning InputValidationError stays tool_result", + body: `{"model":"claude-sonnet-4-5","tools":[{"name":"Bash","input_schema":{"type":"object"}}],"messages":[ + {"role":"user","content":"grep the logs"}, + {"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{}}]}, + {"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"harness.go:42: InputValidationError EnterPlanMode deferred"}]} + ]}`, + want: turntype.ToolResult, + }, } for _, tc := range tests { @@ -346,6 +428,16 @@ func TestDetectFromEnvelope_OpenAI(t *testing.T) { body: `{"model":"gpt-4o","max_tokens":1,"messages":[{"role":"user","content":"quota"}]}`, want: turntype.Probe, }, + { + // Harness-meta detection is gated on Anthropic wire format (the + // markers are a Claude Code emission). A Codex/OpenAI client that + // echoes marker-shaped text must not be escalated. + name: "command-marker text in OpenAI body is not harness_meta", + body: `{"model":"gpt-4o","tools":[{"type":"function","function":{"name":"Bash","parameters":{"type":"object"}}}],"messages":[ + {"role":"user","content":"qplan/qplan\nPlan it, then ExitPlanMode to leave plan mode."} + ]}`, + want: turntype.MainLoop, + }, } for _, tc := range tests { @@ -446,3 +538,43 @@ func TestDetectFromEnvelope_NilEnv(t *testing.T) { got := turntype.DetectFromEnvelope(nil, translate.RoutingFeatures{}, "") assert.Equal(t, turntype.MainLoop, got) } + +func TestTurnType_Base(t *testing.T) { + tests := []struct { + name string + in turntype.TurnType + want turntype.TurnType + }{ + // Harness variants collapse to their underlying shape. + {"harness_meta maps to main_loop", turntype.HarnessMeta, turntype.MainLoop}, + {"sub_agent_harness_meta maps to sub_agent_dispatch", turntype.SubAgentHarnessMeta, turntype.SubAgentDispatch}, + {"recovery maps to tool_result", turntype.Recovery, turntype.ToolResult}, + // Existing values are identity. + {"main_loop identity", turntype.MainLoop, turntype.MainLoop}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, tc.in.Base()) + }) + } +} + +func TestTurnType_HarnessEscalation(t *testing.T) { + tests := []struct { + name string + in turntype.TurnType + want bool + }{ + // The three new values escalate. + {"harness_meta escalates", turntype.HarnessMeta, true}, + {"sub_agent_harness_meta escalates", turntype.SubAgentHarnessMeta, true}, + {"recovery escalates", turntype.Recovery, true}, + // Existing values do not. + {"main_loop does not escalate", turntype.MainLoop, false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, tc.in.HarnessEscalation()) + }) + } +} diff --git a/internal/router/turntype/harness.go b/internal/router/turntype/harness.go new file mode 100644 index 000000000..c7b8a5495 --- /dev/null +++ b/internal/router/turntype/harness.go @@ -0,0 +1,193 @@ +package turntype + +import ( + "strings" + + "workweave/router/internal/translate" +) + +// Bounded prefixes the harness-meta sniffers scan before giving up. A real +// Claude Code command dispatch keeps the skill/command markers + the harness +// reference well within the first few KB; binding the scan protects against +// pathological-but-templated requests padding out the prefix. +const ( + harnessMetaMainTurnScanMaxBytes = 16384 + harnessMetaSubAgentScanMaxBytes = 4096 +) + +// Harness-reference keyword gate (case-insensitive). "plan mode" + "tool +// schema" + "deferred tool" are the three human-language phrases a CC turn +// uses when invoking the harness control plane; false-positive aversion +// matters because the proxy preserves model choice based on this signal. +// Claude-Code-only tool names (matched case-sensitively per the PascalCase +// convention in translate/claudecode_tool_filter.go) are layered on top of +// the phrase match so a sub-agent dispatch whose first user line is +// literally "Load EnterPlanMode tool schema" cannot drift past the gate +// just because it lacks the prose phrases. +var harnessKeywordPhrases = []string{"plan mode", "tool schema", "deferred tool"} + +// harnessMetaCCToolScanNames is the CC-only tool-name set filtered down to +// only names whose length is > 8. The short names ("Task", "Agent", +// "Skill", "Workflow", "LSP", "TaskGet") appear constantly in ordinary +// prompts and even inside non-harness sub-agent dispatch text, so excluding +// them keeps the gate tight: a real harness-meta turn references a SPECIFIC +// tool, not the family name. Built once at init from +// translate.ClaudeCodeOnlyToolNames() so a future rename in the CC tool +// list flows through automatically. +var harnessMetaCCToolScanNames []string + +func init() { + ccMinLen := 8 + for _, name := range translate.ClaudeCodeOnlyToolNames() { + // length filter: drop the short family names. Strict > 8 so + // length-8 ("Workflow", "TaskList", "TaskStop") is excluded along + // with shorter ones — see the const comment for the rationale. + if len(name) <= ccMinLen { + continue + } + harnessMetaCCToolScanNames = append(harnessMetaCCToolScanNames, name) + } +} + +// referencesHarnessPrimitives returns true when text is plausibly invoking +// a harness control-plane primitive. Prose phrases are matched +// case-insensitively (LLM text is loose); CC-only tool names are matched +// case-sensitively with word boundaries so a backticked/quoted name hits +// while an incidental mention like "MyToolSearchThing" or "TaskProvider" +// does not. +func referencesHarnessPrimitives(text string) bool { + if text == "" { + return false + } + lower := strings.ToLower(text) + for _, phrase := range harnessKeywordPhrases { + if strings.Contains(lower, phrase) { + return true + } + } + for _, name := range harnessMetaCCToolScanNames { + if containsWord(text, name) { + return true + } + } + return false +} + +// containsWord reports whether needle appears in haystack as a +// contiguous run framed by non-word runes (anything outside [A-Za-z0-9_]). +// Case-sensitive per the PascalCase rule in +// translate/claudecode_tool_filter.go:65-68 (CC emits tool names verbatim). +// Bounded search protects against pathological inputs but no input here +// exceeds harnessMetaMainTurnScanMaxBytes / harnessMetaSubAgentScanMaxBytes. +func containsWord(haystack, needle string) bool { + for start := 0; start <= len(haystack)-len(needle); { + idx := strings.Index(haystack[start:], needle) + if idx < 0 { + return false + } + idx += start + leftOK := idx == 0 || !isWordRune(rune(haystack[idx-1])) + end := idx + len(needle) + rightOK := end == len(haystack) || !isWordRune(rune(haystack[end])) + if leftOK && rightOK { + return true + } + // advance past this candidate index to keep scanning. + start = idx + 1 + } + return false +} + +func isWordRune(r rune) bool { + switch { + case r >= 'a' && r <= 'z': + return true + case r >= 'A' && r <= 'Z': + return true + case r >= '0' && r <= '9': + return true + case r == '_': + return true + } + return false +} + +// isHarnessMetaSubAgent reports whether the first user text of a sub-agent +// dispatch prompt references harness primitives. Bounded by +// harnessMetaSubAgentScanMaxBytes so we never walk the entirety of a +// long dispatch prompt — the harness signal always sits in the first KB +// of the dispatched prompt (CC's Agent tool template). +func isHarnessMetaSubAgent(firstUserText string) bool { + if firstUserText == "" { + return false + } + scanned := firstUserText + if len(scanned) > harnessMetaSubAgentScanMaxBytes { + scanned = scanned[:harnessMetaSubAgentScanMaxBytes] + } + return referencesHarnessPrimitives(scanned) +} + +// isHarnessMetaMainTurn reports whether the last user message in a +// non-sub-agent, non-classifier main turn is a Claude Code skill/command +// invocation referencing harness primitives. Requires BOTH a command +// marker (Claude Code emits "..." and +// "..." verbatim for slash and skill +// invocations) AND the shared harness-reference gate — the AND keeps +// ordinary slash commands like /standup or /help from escalating. +func isHarnessMetaMainTurn(env *translate.RequestEnvelope) bool { + if env == nil { + return false + } + text := env.LastUserMessage().Text + if text == "" { + return false + } + if len(text) > harnessMetaMainTurnScanMaxBytes { + text = text[:harnessMetaMainTurnScanMaxBytes] + } + if !strings.Contains(text, "") && !strings.Contains(text, "") { + return false + } + return referencesHarnessPrimitives(text) +} + +// isRecoveryTurn reports whether this is a tool_result turn recovering +// from a deferred-tool InputValidationError. Requires BOTH: +// - feats.LastKind == "tool_result" (gated by the caller; re-checked +// defensively), +// - the errored tool_result payload contains "InputValidationError", +// - and either the substring "deferred" (any case) OR any CC-only tool +// name matched by the shared harness gate. +// +// The hard AND prevents ordinary schema-mistake retries (wrong parameter +// type on Bash, etc.) from being misclassified as harness control-plane +// failures — only the ones whose failure mentions a deferred-tool or other +// harness primitive are gated up. +func isRecoveryTurn(env *translate.RequestEnvelope, feats translate.RoutingFeatures) bool { + if env == nil { + return false + } + if feats.LastKind != "tool_result" { + return false + } + errText := env.LastUserToolResultErrorText(harnessMetaMainTurnScanMaxBytes) + if errText == "" { + return false + } + if !strings.Contains(errText, "InputValidationError") { + return false + } + return hasDeferredToolContext(errText) +} + +// hasDeferredToolContext reports whether the errored text references a +// deferred-tool context: literally the substring "deferred" (any case) OR +// any CC-only tool name. Same case-sensitivity rules as +// referencesHarnessPrimitives for the tool-name sweep. +func hasDeferredToolContext(text string) bool { + if strings.Contains(strings.ToLower(text), "deferred") { + return true + } + return referencesHarnessPrimitives(text) +} diff --git a/internal/translate/claudecode_tool_filter.go b/internal/translate/claudecode_tool_filter.go index e91d15b95..cb1c28a61 100644 --- a/internal/translate/claudecode_tool_filter.go +++ b/internal/translate/claudecode_tool_filter.go @@ -1,6 +1,8 @@ package translate import ( + "sort" + "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -71,6 +73,20 @@ func isClaudeCodeOnlyTool(name string) bool { return ok } +// ClaudeCodeOnlyToolNames returns a sorted copy of the Claude-Code-only tool +// names. Exported for the turntype package's harness-reference gate, which +// scans prompt text for these names to detect harness-protocol-bound turns; +// sharing the list keeps the gate in sync with the emit-path filter instead +// of duplicating the name set. Sorted for deterministic iteration. +func ClaudeCodeOnlyToolNames() []string { + names := make([]string, 0, len(claudeCodeOnlyToolNames)) + for name := range claudeCodeOnlyToolNames { + names = append(names, name) + } + sort.Strings(names) + return names +} + // claudeCodeOrchestrationToolNames is the subset of claudeCodeOnlyToolNames // a capable non-Anthropic model can act on. Must stay a strict subset of // claudeCodeOnlyToolNames — see TestOrchestrationToolsAreSubsetOfCCOnly. diff --git a/internal/translate/force_model.go b/internal/translate/force_model.go index 80d272513..a96d3c949 100644 --- a/internal/translate/force_model.go +++ b/internal/translate/force_model.go @@ -17,6 +17,11 @@ const ReasonUserForceModel = "user_forced" // ReasonUserForceModel, so the session doesn't re-route back into the loop. const ReasonLoopEscalation = "loop_escalation" +// ReasonHarnessEscalation marks a per-turn decision replaced by the +// harness-protocol escalation clamp (route up, never down). Per-TURN only — +// never written as a session-pin reason. +const ReasonHarnessEscalation = "harness_escalation" + // ForceModelResult holds the parsed outcome of a force-model command. type ForceModelResult struct { // Model is the target model name; empty when Clear is true. diff --git a/internal/translate/lastuser_toolresult.go b/internal/translate/lastuser_toolresult.go new file mode 100644 index 000000000..e06458dff --- /dev/null +++ b/internal/translate/lastuser_toolresult.go @@ -0,0 +1,99 @@ +package translate + +import ( + "strings" + + "github.com/tidwall/gjson" +) + +// LastUserToolResultErrorText returns the concatenated text of the last user +// message's errored (is_error: true) tool_result blocks, truncated to +// maxBytes. Anthropic format only — returns "" for OpenAI/Gemini, whose +// tool-failure shapes carry no equivalent is_error marker on the wire. +// +// Exists separately from LastUserMessageInfo because the turntype harness +// gate needs the failure text itself, not just the presence/size of a +// tool_result: distinguishing a deferred-tool InputValidationError from an +// ordinary schema mistake requires reading the payload. +// +// tool_result `content` may be a plain string OR an array of typed blocks +// ({"type":"text","text":...}); both shapes are handled. Blocks are joined +// with a newline. maxBytes <= 0 returns "". +func (e *RequestEnvelope) LastUserToolResultErrorText(maxBytes int) string { + if e.format != FormatAnthropic || maxBytes <= 0 { + return "" + } + msgs := gjson.GetBytes(e.body, "messages") + if !msgs.IsArray() { + return "" + } + var lastUser gjson.Result + msgs.ForEach(func(_, msg gjson.Result) bool { + if msg.Get("role").String() == "user" { + lastUser = msg + } + return true + }) + if !lastUser.Exists() { + return "" + } + content := lastUser.Get("content") + if !content.IsArray() { + return "" + } + + var b strings.Builder + content.ForEach(func(_, block gjson.Result) bool { + if block.Get("type").String() != "tool_result" { + return true + } + if !block.Get("is_error").Bool() { + return true + } + text := toolResultBlockText(block.Get("content")) + if text == "" { + return true + } + if b.Len() > 0 { + b.WriteByte('\n') + } + b.WriteString(text) + // Keep accumulating only until we have enough to satisfy maxBytes; + // a huge errored payload should not be fully walked. + return b.Len() < maxBytes + }) + + out := b.String() + if len(out) > maxBytes { + return out[:maxBytes] + } + return out +} + +// toolResultBlockText extracts text from a tool_result block's `content`, +// which Anthropic accepts either as a plain string or as an array of typed +// blocks. Non-text blocks (e.g. image) contribute nothing. +func toolResultBlockText(content gjson.Result) string { + if content.Type == gjson.String { + return content.String() + } + if !content.IsArray() { + return "" + } + var b strings.Builder + content.ForEach(func(_, part gjson.Result) bool { + if part.Get("type").String() != "text" { + return true + } + text := part.Get("text").String() + if text == "" { + return true + } + if b.Len() > 0 { + b.WriteByte('\n') + } + b.WriteString(text) + return true + }) + return b.String() +} diff --git a/internal/translate/lastuser_toolresult_test.go b/internal/translate/lastuser_toolresult_test.go new file mode 100644 index 000000000..338c8af01 --- /dev/null +++ b/internal/translate/lastuser_toolresult_test.go @@ -0,0 +1,134 @@ +package translate_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "workweave/router/internal/translate" +) + +func TestLastUserToolResultErrorText(t *testing.T) { + t.Run("non-anthropic format returns empty string", func(t *testing.T) { + // OpenAI: tool-failure is encoded differently and is not a wire-level + // is_error marker we can read, so the accessor returns "". + body := []byte(`{"model":"gpt-4o","messages":[ + {"role":"user","content":"run grep"}, + {"role":"assistant","content":null,"tool_calls":[{"id":"t1","type":"function","function":{"name":"Bash","arguments":"{}"}}]}, + {"role":"tool","tool_call_id":"t1","content":"InputValidationError: schema mismatch"} + ]}`) + env, err := translate.ParseOpenAI(body) + require.NoError(t, err) + assert.Equal(t, "", env.LastUserToolResultErrorText(4096)) + }) + + t.Run("array-form errored tool_result text is concatenated", func(t *testing.T) { + body := []byte(`{"model":"claude-sonnet-4-5","tools":[],"messages":[ + {"role":"user","content":"run"}, + {"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"EnterPlanMode","input":{}}]}, + {"role":"user","content":[ + {"type":"tool_result","tool_use_id":"t1","is_error":true,"content":[ + {"type":"text","text":"InputValidationError: needs deferred schema for EnterPlanMode"} + ]}, + {"type":"text","text":"trailing user note ignored"} + ]} + ]}`) + env, err := translate.ParseAnthropic(body) + require.NoError(t, err) + got := env.LastUserToolResultErrorText(8192) + assert.Contains(t, got, "InputValidationError") + assert.Contains(t, got, "EnterPlanMode") + }) + + t.Run("plain-string errored tool_result text is returned", func(t *testing.T) { + body := []byte(`{"model":"claude-opus-4-7","tools":[],"messages":[ + {"role":"user","content":"hi"}, + {"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"X","input":{}}]}, + {"role":"user","content":[ + {"type":"tool_result","tool_use_id":"t1","is_error":true,"content":"InputValidationError: bad param"} + ]} + ]}`) + env, err := translate.ParseAnthropic(body) + require.NoError(t, err) + assert.Contains(t, env.LastUserToolResultErrorText(4096), "InputValidationError") + }) + + t.Run("non-errored block with same text is not surfaced", func(t *testing.T) { + // Successful tool_result printing InputValidationError must not be + // picked up — the harness gate's Recovery classifier depends on this + // (its is_error gate is the load-bearing half). + body := []byte(`{"model":"claude-sonnet-4-5","tools":[],"messages":[ + {"role":"user","content":"grep"}, + {"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{}}]}, + {"role":"user","content":[ + {"type":"tool_result","tool_use_id":"t1","content":"found InputValidationError in logs"} + ]} + ]}`) + env, err := translate.ParseAnthropic(body) + require.NoError(t, err) + assert.Equal(t, "", env.LastUserToolResultErrorText(4096)) + }) + + t.Run("max bytes truncates output", func(t *testing.T) { + pad := "x" + for i := 0; i < 50; i++ { + pad += "x" + } + body := []byte(`{"model":"claude-sonnet-4-5","tools":[],"messages":[ + {"role":"user","content":"r"}, + {"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"X","input":{}}]}, + {"role":"user","content":[ + {"type":"tool_result","tool_use_id":"t1","is_error":true,"content":"InputValidationError ` + pad + ` long tail"} + ]} + ]}`) + env, err := translate.ParseAnthropic(body) + require.NoError(t, err) + got := env.LastUserToolResultErrorText(64) + assert.LessOrEqual(t, len(got), 64) + }) + + t.Run("max bytes <= 0 returns empty", func(t *testing.T) { + body := []byte(`{"model":"claude-opus-4-7","tools":[],"messages":[ + {"role":"user","content":"r"}, + {"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"X","input":{}}]}, + {"role":"user","content":[ + {"type":"tool_result","tool_use_id":"t1","is_error":true,"content":"InputValidationError"} + ]} + ]}`) + env, err := translate.ParseAnthropic(body) + require.NoError(t, err) + assert.Equal(t, "", env.LastUserToolResultErrorText(0)) + }) + + t.Run("only the last user message is read", func(t *testing.T) { + // Earlier errored tool_result must be ignored; the gate looks at + // the LAST user-side input, which is the context the next turn has + // to act on. + body := []byte(`{"model":"claude-sonnet-4-5","tools":[],"messages":[ + {"role":"user","content":"first step"}, + {"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"X","input":{}}]}, + {"role":"user","content":[ + {"type":"tool_result","tool_use_id":"t1","is_error":true,"content":"InputValidationError OLD harness failure"} + ]}, + {"role":"assistant","content":[{"type":"text","text":"retrying..."}]}, + {"role":"user","content":[ + {"type":"tool_result","tool_use_id":"t2","is_error":true,"content":"InputValidationError fresh recovery context with EnterPlanMode"} + ]} + ]}`) + env, err := translate.ParseAnthropic(body) + require.NoError(t, err) + got := env.LastUserToolResultErrorText(4096) + // Last user message wins; earlier "OLD" payload must be ignored. + assert.NotContains(t, got, "OLD") + // Last payload's recovery-style text is what the gate sees. + assert.Contains(t, got, "fresh recovery context") + }) + + t.Run("no user messages yields empty", func(t *testing.T) { + body := []byte(`{"model":"claude-opus-4-7","tools":[],"messages":[]}`) + env, err := translate.ParseAnthropic(body) + require.NoError(t, err) + assert.Equal(t, "", env.LastUserToolResultErrorText(4096)) + }) +} From 8aad6320def636e7f9aac2a78867cf1298990fd7 Mon Sep 17 00:00:00 2001 From: Steven Tohme Date: Tue, 18 Aug 2026 13:09:16 +0000 Subject: [PATCH 2/8] fix(routing): close harness escalation gaps --- internal/proxy/claimed_tool_unavailable.go | 17 +++----- .../claimed_tool_unavailable_internal_test.go | 18 ++++++++ internal/proxy/harness_escalation.go | 25 +++-------- internal/proxy/service.go | 1 + internal/router/turntype/CLAUDE.md | 4 +- internal/router/turntype/detect.go | 2 +- internal/router/turntype/detect_test.go | 18 ++++++++ internal/router/turntype/harness.go | 43 ++++++------------- internal/translate/lastuser_toolresult.go | 15 ++----- 9 files changed, 70 insertions(+), 73 deletions(-) diff --git a/internal/proxy/claimed_tool_unavailable.go b/internal/proxy/claimed_tool_unavailable.go index 07b4cc07f..0c8fb59f7 100644 --- a/internal/proxy/claimed_tool_unavailable.go +++ b/internal/proxy/claimed_tool_unavailable.go @@ -1,9 +1,6 @@ // The claimed-tool-unavailable detector flags a routed model that says a -// tool is not in its toolset while the request actually declared that tool — -// a detectable model failure. Post-stream: capture-gated (no respBody means -// no-op, e.g. self-hosted no-capture deploys). Persist-only — writes a -// source="auto" negative RouterFeedbackEvent offline; it never influences -// routing, pins, or the live reward loop (ML consumes the auto rows offline). +// declared tool is unavailable. It persists source="auto" negative feedback +// after capture, without influencing routing or pins. package proxy import ( @@ -152,12 +149,10 @@ func nonStreamingText(respBody []byte) string { } // detectClaimedToolUnavailable scans text for each declared tool name and -// reports the names the model claims unavailable. Name matching is -// case-SENSITIVE with word boundaries (a name nested inside a longer -// identifier does not count); around each occurrence a lowercased -// ±claimedToolWindowBytes window is checked against claimedUnavailablePhrases, -// or a "no "/"there is no " immediately preceding the name. Returns deduped -// findings capped at claimedToolMaxFindings. Pure — unit-test directly. +// reports names claimed unavailable: case-sensitive word-boundary match, +// ±claimedToolWindowBytes window against claimedUnavailablePhrases or a +// preceding "no"/"there is no". Returns deduped findings capped at +// claimedToolMaxFindings. Pure — unit-test directly. func detectClaimedToolUnavailable(text string, availableTools []string) []string { if text == "" || len(availableTools) == 0 { return nil diff --git a/internal/proxy/claimed_tool_unavailable_internal_test.go b/internal/proxy/claimed_tool_unavailable_internal_test.go index 56071a545..4d341ef1f 100644 --- a/internal/proxy/claimed_tool_unavailable_internal_test.go +++ b/internal/proxy/claimed_tool_unavailable_internal_test.go @@ -246,6 +246,24 @@ func TestMaybeReportClaimedToolUnavailable_InsertsOneEvent(t *testing.T) { assert.Equal(t, "sess-1", ev.SessionID) } +func TestNewService_InitializesClaimedToolTracker(t *testing.T) { + store := &claimedToolFakeStore{} + svc := NewService(nil, nil, nil, false, nil, nil, false, "", "", nil). + WithRouterFeedbackStore(store) + installationID, sessionKey := claimedToolHost() + body := []byte(`{"content":[{"type":"text","text":"There is no ToolSearch tool in my available toolset."}]}`) + + svc.maybeReportClaimedToolUnavailable( + context.Background(), body, false, []string{"ToolSearch"}, + installationID, sessionKey, "default_high", "claude-sonnet-5", "qwen3", + "req-new-service", "route-1", ClientIdentity{}, + ) + + events := store.snap() + require.Len(t, events, 1) + assert.Equal(t, "claimed-tool-unavailable:ToolSearch", events[0].Feedback) +} + func TestMaybeReportClaimedToolUnavailable_DedupesSecondCall(t *testing.T) { store := &claimedToolFakeStore{} svc := claimedToolService(store) diff --git a/internal/proxy/harness_escalation.go b/internal/proxy/harness_escalation.go index 3b7937a96..df614e18a 100644 --- a/internal/proxy/harness_escalation.go +++ b/internal/proxy/harness_escalation.go @@ -1,14 +1,8 @@ -// The harness-protocol escalation clamp is a deterministic per-turn guard that -// routes harness-bound turns UP, never down. Detected harness variants -// (HarnessMeta, SubAgentHarnessMeta, Recovery — see turntype.HarnessEscalation) -// operate the harness control plane (plan-mode tools, deferred-tool discovery, -// recovery from harness-shape failures); a non-Anthropic or weak upstream that -// hallucinates those primitives corrupts the client's harness state. So once -// the routing decision resolves, if the chosen model is not a strong -// Claude-family TierHigh model the decision is replaced with the escalation -// target. The clamp is per-TURN and non-persisted — it only rewrites the -// current decision; it never touches session pins, so the next turn routes -// through the normal pipeline again. +// Package-level doc: harness-bound turns (HarnessMeta, SubAgentHarnessMeta, +// Recovery) must be served by a strong Claude-family model; a weak or +// non-Anthropic upstream that hallucinates harness primitives corrupts the +// client's harness state. applyHarnessEscalation enforces this per-turn, +// non-persisted (never touches session pins). package proxy import ( @@ -55,13 +49,8 @@ const ( ) // applyHarnessEscalation clamps a resolved harness-protocol turn decision to a -// strong Claude-family model (route up, never down). It runs exactly once per -// turn, after routing, as the single choke point on every decision path. -// -// The clamp is a deterministic decision rewrite and never errors: every branch -// either leaves res.Decision untouched (logging why) or replaces it with -// {anthropic, escalateModel}. Pins, PinTier, StickyHit and Fresh are never -// mutated — the next turn routes fresh. +// strong Claude-family model (route up, never down). It rewrites only the +// current decision; pins, PinTier, StickyHit, and Fresh remain unchanged. func (s *Service) applyHarnessEscalation(ctx context.Context, res *turnLoopResult, req router.Request) { if !res.TurnType.HarnessEscalation() { return diff --git a/internal/proxy/service.go b/internal/proxy/service.go index 4c230b427..94baf7e83 100644 --- a/internal/proxy/service.go +++ b/internal/proxy/service.go @@ -1063,6 +1063,7 @@ func NewService(r router.Router, providerMap map[string]providers.Client, emitte compaction: newCompactionTracker(), prefixTrimFreeSwitch: true, spiralTracker: newSpiralTracker(), + claimedToolTracker: newClaimedToolTracker(), spiralShadowEnabled: true, textRepetitionBreakEnabled: true, ccOrchToolsCrossVendor: true, diff --git a/internal/router/turntype/CLAUDE.md b/internal/router/turntype/CLAUDE.md index bc3ea57f8..36a0d51f9 100644 --- a/internal/router/turntype/CLAUDE.md +++ b/internal/router/turntype/CLAUDE.md @@ -1,6 +1,6 @@ -# internal/router/turntype — CLAUDE +# internal/router/turntype — AGENTS -> **Mirror notice.** Verbatim sync with [AGENTS.md](AGENTS.md). **Update both together** — divergence = bug. +> **Mirror notice.** Verbatim sync with [CLAUDE.md](CLAUDE.md). **Update both together** — divergence = bug. Inbound turn-type classifier. Read [root CLAUDE.md](../../../CLAUDE.md) first. diff --git a/internal/router/turntype/detect.go b/internal/router/turntype/detect.go index be5028ae2..640275e0a 100644 --- a/internal/router/turntype/detect.go +++ b/internal/router/turntype/detect.go @@ -78,7 +78,7 @@ func DetectFromEnvelope(env *translate.RequestEnvelope, feats translate.RoutingF return Compaction } if isSubAgentDispatch(env.MetadataUserID(), env.FirstUserMessageText(), subAgentHint) { - if isHarnessMetaSubAgent(env.FirstUserMessageText()) { + if env.SourceFormat() == translate.FormatAnthropic && isHarnessMetaSubAgent(env.FirstUserMessageText()) { return SubAgentHarnessMeta } return SubAgentDispatch diff --git a/internal/router/turntype/detect_test.go b/internal/router/turntype/detect_test.go index 22d5967e1..f4bc84e53 100644 --- a/internal/router/turntype/detect_test.go +++ b/internal/router/turntype/detect_test.go @@ -276,6 +276,12 @@ func TestDetectFromEnvelope_Anthropic(t *testing.T) { body: `{"model":"claude-opus-4-7","messages":[{"role":"user","content":"\nUser: Load EnterPlanMode tool schema\n"}]}`, want: turntype.SubAgentHarnessMeta, }, + { + name: "sub-agent dispatch asking for plan mode is sub_agent_harness_meta", + body: `{"model":"claude-haiku-4-5","messages":[{"role":"user","content":"Enter plan mode before editing the files"}]}`, + hint: "general-purpose", + want: turntype.SubAgentHarnessMeta, + }, { // Regression guard: an ordinary code-search dispatch must stay on // the cheap sub-agent path. Escalating every sub-agent would undo @@ -438,6 +444,12 @@ func TestDetectFromEnvelope_OpenAI(t *testing.T) { ]}`, want: turntype.MainLoop, }, + { + name: "sub-agent dispatch mentioning plan mode stays sub_agent_dispatch", + body: `{"model":"gpt-4o","messages":[{"role":"user","content":"Enter plan mode before editing the files"}]}`, + hint: "Explore", + want: turntype.SubAgentDispatch, + }, } for _, tc := range tests { @@ -521,6 +533,12 @@ func TestDetectFromEnvelope_Gemini(t *testing.T) { body: `{"generationConfig":{"maxOutputTokens":1},"contents":[{"role":"user","parts":[{"text":"quota"}]}]}`, want: turntype.Probe, }, + { + name: "sub-agent dispatch mentioning plan mode stays sub_agent_dispatch", + body: `{"contents":[{"role":"user","parts":[{"text":"Enter plan mode before editing the files"}]}]}`, + hint: "Explore", + want: turntype.SubAgentDispatch, + }, } for _, tc := range tests { diff --git a/internal/router/turntype/harness.go b/internal/router/turntype/harness.go index c7b8a5495..f523decdc 100644 --- a/internal/router/turntype/harness.go +++ b/internal/router/turntype/harness.go @@ -15,25 +15,16 @@ const ( harnessMetaSubAgentScanMaxBytes = 4096 ) -// Harness-reference keyword gate (case-insensitive). "plan mode" + "tool -// schema" + "deferred tool" are the three human-language phrases a CC turn -// uses when invoking the harness control plane; false-positive aversion -// matters because the proxy preserves model choice based on this signal. -// Claude-Code-only tool names (matched case-sensitively per the PascalCase -// convention in translate/claudecode_tool_filter.go) are layered on top of -// the phrase match so a sub-agent dispatch whose first user line is -// literally "Load EnterPlanMode tool schema" cannot drift past the gate -// just because it lacks the prose phrases. +// Harness-reference keyword gate (case-insensitive). Phrases cover +// human-language harness control-plane invocations; CC-only tool names +// (case-sensitive word boundaries) layer on top so a dispatch like +// "Load EnterPlanMode tool schema" is caught even without the prose phrases. var harnessKeywordPhrases = []string{"plan mode", "tool schema", "deferred tool"} -// harnessMetaCCToolScanNames is the CC-only tool-name set filtered down to -// only names whose length is > 8. The short names ("Task", "Agent", -// "Skill", "Workflow", "LSP", "TaskGet") appear constantly in ordinary -// prompts and even inside non-harness sub-agent dispatch text, so excluding -// them keeps the gate tight: a real harness-meta turn references a SPECIFIC -// tool, not the family name. Built once at init from -// translate.ClaudeCodeOnlyToolNames() so a future rename in the CC tool -// list flows through automatically. +// harnessMetaCCToolScanNames is the CC-only tool-name set filtered to names +// longer than 8 bytes. Short names ("Task", "Agent", "Workflow") appear in +// ordinary prompts and would false-positive; specific long names are safe. +// Built once at init from translate.ClaudeCodeOnlyToolNames(). var harnessMetaCCToolScanNames []string func init() { @@ -92,7 +83,6 @@ func containsWord(haystack, needle string) bool { if leftOK && rightOK { return true } - // advance past this candidate index to keep scanning. start = idx + 1 } return false @@ -152,18 +142,11 @@ func isHarnessMetaMainTurn(env *translate.RequestEnvelope) bool { return referencesHarnessPrimitives(text) } -// isRecoveryTurn reports whether this is a tool_result turn recovering -// from a deferred-tool InputValidationError. Requires BOTH: -// - feats.LastKind == "tool_result" (gated by the caller; re-checked -// defensively), -// - the errored tool_result payload contains "InputValidationError", -// - and either the substring "deferred" (any case) OR any CC-only tool -// name matched by the shared harness gate. -// -// The hard AND prevents ordinary schema-mistake retries (wrong parameter -// type on Bash, etc.) from being misclassified as harness control-plane -// failures — only the ones whose failure mentions a deferred-tool or other -// harness primitive are gated up. +// isRecoveryTurn reports whether this tool_result turn is recovering from a +// deferred-tool InputValidationError. Requires both "InputValidationError" in +// the errored payload AND a deferred/harness-primitive reference — the AND +// prevents ordinary schema-mistake retries (wrong Bash param type) from being +// misclassified as harness control-plane failures. func isRecoveryTurn(env *translate.RequestEnvelope, feats translate.RoutingFeatures) bool { if env == nil { return false diff --git a/internal/translate/lastuser_toolresult.go b/internal/translate/lastuser_toolresult.go index e06458dff..56ec9df77 100644 --- a/internal/translate/lastuser_toolresult.go +++ b/internal/translate/lastuser_toolresult.go @@ -8,17 +8,10 @@ import ( // LastUserToolResultErrorText returns the concatenated text of the last user // message's errored (is_error: true) tool_result blocks, truncated to -// maxBytes. Anthropic format only — returns "" for OpenAI/Gemini, whose -// tool-failure shapes carry no equivalent is_error marker on the wire. -// -// Exists separately from LastUserMessageInfo because the turntype harness -// gate needs the failure text itself, not just the presence/size of a -// tool_result: distinguishing a deferred-tool InputValidationError from an -// ordinary schema mistake requires reading the payload. -// -// tool_result `content` may be a plain string OR an array of typed blocks -// ({"type":"text","text":...}); both shapes are handled. Blocks are joined -// with a newline. maxBytes <= 0 returns "". +// maxBytes. Anthropic format only (OpenAI/Gemini carry no equivalent is_error marker). +// Exists separately from LastUserMessageInfo because the turntype harness gate +// needs the failure text to distinguish a deferred-tool InputValidationError +// from an ordinary schema mistake. maxBytes <= 0 returns "". func (e *RequestEnvelope) LastUserToolResultErrorText(maxBytes int) string { if e.format != FormatAnthropic || maxBytes <= 0 { return "" From 7a422411ca53dcd6735656003ab91c7ae81269b2 Mon Sep 17 00:00:00 2001 From: Steven Tohme Date: Tue, 18 Aug 2026 13:15:45 +0000 Subject: [PATCH 3/8] style(routing): shorten harness comments --- internal/proxy/claimed_tool_unavailable.go | 28 ++++++++------------- internal/proxy/harness_escalation.go | 9 +++---- internal/router/turntype/detect.go | 11 +++----- internal/router/turntype/harness.go | 29 ++++++++-------------- 4 files changed, 28 insertions(+), 49 deletions(-) diff --git a/internal/proxy/claimed_tool_unavailable.go b/internal/proxy/claimed_tool_unavailable.go index 0c8fb59f7..97a33183c 100644 --- a/internal/proxy/claimed_tool_unavailable.go +++ b/internal/proxy/claimed_tool_unavailable.go @@ -18,11 +18,9 @@ import ( "github.com/tidwall/gjson" ) -// claimedUnavailablePhrases are the "claimed unavailable" tell-strings matched -// against the lowercased ±160-byte window around a declared tool name. Each is -// a loose substring: "not available" captures "is not available" / "not -// available in my toolset", "don't have access" covers "I don't have access". -// Keep this a package var so tests pin the exact list. +// claimedUnavailablePhrases are the loose substrings matched against the +// lowercased ±160-byte window around a declared tool name. +// Package var so tests pin the exact list. var claimedUnavailablePhrases = []string{ "not available", "isn't available", @@ -80,11 +78,9 @@ func claimedToolFiredKey(sessionKey [sessionpin.SessionKeyLen]byte, role, tool s return string(sessionKey[:]) + "\x00" + role + "\x00" + tool } -// claimedToolUnavailableFromBody extracts the model's response text from the -// captured client-bound bytes — Anthropic SSE frames when streaming, a single -// JSON body otherwise — and reports the declared tools it claims unavailable. -// Malformed/truncated input fails open (returns no findings): the capture cap -// can cut mid-frame, and this detector must never error or panic. +// claimedToolUnavailableFromBody extracts response text (SSE or JSON) and +// returns declared tools the model claims unavailable. Malformed input fails +// open (no findings) — the capture cap can cut mid-frame. func claimedToolUnavailableFromBody(respBody []byte, streaming bool, availableTools []string) []string { if !streaming { return detectClaimedToolUnavailable(nonStreamingText(respBody), availableTools) @@ -149,10 +145,8 @@ func nonStreamingText(respBody []byte) string { } // detectClaimedToolUnavailable scans text for each declared tool name and -// reports names claimed unavailable: case-sensitive word-boundary match, -// ±claimedToolWindowBytes window against claimedUnavailablePhrases or a -// preceding "no"/"there is no". Returns deduped findings capped at -// claimedToolMaxFindings. Pure — unit-test directly. +// reports names the model claims unavailable, capped at claimedToolMaxFindings. +// Pure — unit-test directly. func detectClaimedToolUnavailable(text string, availableTools []string) []string { if text == "" || len(availableTools) == 0 { return nil @@ -257,10 +251,8 @@ func endsWithNoPhrase(lower string) bool { } // maybeReportClaimedToolUnavailable persists one source="auto" negative -// RouterFeedbackEvent per (session, role, tool) when the captured response -// text claims a declared tool is unavailable. Best-effort and off the -// response path: capture-gated (no respBody -> no-op), never influences -// routing, and hard stops on nil deps. +// RouterFeedbackEvent per (session, role, tool). Best-effort, off the response +// path; no-ops on nil deps or missing respBody/tools. func (s *Service) maybeReportClaimedToolUnavailable( ctx context.Context, respBody []byte, diff --git a/internal/proxy/harness_escalation.go b/internal/proxy/harness_escalation.go index df614e18a..b676beacb 100644 --- a/internal/proxy/harness_escalation.go +++ b/internal/proxy/harness_escalation.go @@ -1,8 +1,7 @@ -// Package-level doc: harness-bound turns (HarnessMeta, SubAgentHarnessMeta, -// Recovery) must be served by a strong Claude-family model; a weak or -// non-Anthropic upstream that hallucinates harness primitives corrupts the -// client's harness state. applyHarnessEscalation enforces this per-turn, -// non-persisted (never touches session pins). +// applyHarnessEscalation clamps harness-bound turns (HarnessMeta, +// SubAgentHarnessMeta, Recovery) to a strong Claude-family model — a weak or +// non-Anthropic upstream that hallucinates harness primitives corrupts client +// harness state. Per-turn only; never touches session pins. package proxy import ( diff --git a/internal/router/turntype/detect.go b/internal/router/turntype/detect.go index 640275e0a..b30851177 100644 --- a/internal/router/turntype/detect.go +++ b/internal/router/turntype/detect.go @@ -163,13 +163,10 @@ func isSubAgentDispatch(metadataUserID, firstUserText, subAgentHint string) bool return strings.Contains(prefix, "") } -// Base returns the underlying turn shape a harness variant should be treated -// as at call sites that must keep behavior-compatible with the pre-harness -// vocabulary (the policy sidecar in particular: its turn-type labels must -// stay stable so a deployed roster does not need to re-publish when a new -// harness detection lands). Maps HarnessMeta → MainLoop, -// SubAgentHarnessMeta → SubAgentDispatch, Recovery → ToolResult; identity -// for every other value. +// Base returns the underlying turn shape for call sites keeping the pre-harness +// vocabulary stable (policy sidecar labels must not change when new harness +// detections land). HarnessMeta → MainLoop, SubAgentHarnessMeta → +// SubAgentDispatch, Recovery → ToolResult; identity otherwise. func (t TurnType) Base() TurnType { switch t { case HarnessMeta: diff --git a/internal/router/turntype/harness.go b/internal/router/turntype/harness.go index f523decdc..3e7f9acb7 100644 --- a/internal/router/turntype/harness.go +++ b/internal/router/turntype/harness.go @@ -40,12 +40,9 @@ func init() { } } -// referencesHarnessPrimitives returns true when text is plausibly invoking -// a harness control-plane primitive. Prose phrases are matched -// case-insensitively (LLM text is loose); CC-only tool names are matched -// case-sensitively with word boundaries so a backticked/quoted name hits -// while an incidental mention like "MyToolSearchThing" or "TaskProvider" -// does not. +// referencesHarnessPrimitives reports whether text plausibly invokes a harness +// control-plane primitive. Prose phrases are matched case-insensitively; CC-only +// tool names use case-sensitive word boundaries. func referencesHarnessPrimitives(text string) bool { if text == "" { return false @@ -64,12 +61,9 @@ func referencesHarnessPrimitives(text string) bool { return false } -// containsWord reports whether needle appears in haystack as a -// contiguous run framed by non-word runes (anything outside [A-Za-z0-9_]). -// Case-sensitive per the PascalCase rule in -// translate/claudecode_tool_filter.go:65-68 (CC emits tool names verbatim). -// Bounded search protects against pathological inputs but no input here -// exceeds harnessMetaMainTurnScanMaxBytes / harnessMetaSubAgentScanMaxBytes. +// containsWord reports whether needle appears in haystack with word boundaries +// (outside [A-Za-z0-9_]). Case-sensitive: CC emits tool names verbatim +// (PascalCase), matching translate/claudecode_tool_filter.go:65-68. func containsWord(haystack, needle string) bool { for start := 0; start <= len(haystack)-len(needle); { idx := strings.Index(haystack[start:], needle) @@ -118,13 +112,10 @@ func isHarnessMetaSubAgent(firstUserText string) bool { return referencesHarnessPrimitives(scanned) } -// isHarnessMetaMainTurn reports whether the last user message in a -// non-sub-agent, non-classifier main turn is a Claude Code skill/command -// invocation referencing harness primitives. Requires BOTH a command -// marker (Claude Code emits "..." and -// "..." verbatim for slash and skill -// invocations) AND the shared harness-reference gate — the AND keeps -// ordinary slash commands like /standup or /help from escalating. +// isHarnessMetaMainTurn reports whether the last user message is a Claude Code +// skill/command invocation referencing harness primitives. Requires BOTH a +// command marker AND the harness-reference gate — the AND prevents ordinary +// slash commands like /standup from escalating. func isHarnessMetaMainTurn(env *translate.RequestEnvelope) bool { if env == nil { return false From 7fa2f2d3cb7ae6e237e57dacafa2a373ae5ff014 Mon Sep 17 00:00:00 2001 From: Steven Tohme Date: Tue, 18 Aug 2026 13:21:31 +0000 Subject: [PATCH 4/8] style(routing): trim remaining harness comments --- internal/router/turntype/detect.go | 32 +++++++++++------------------ internal/router/turntype/harness.go | 12 ++++------- 2 files changed, 16 insertions(+), 28 deletions(-) diff --git a/internal/router/turntype/detect.go b/internal/router/turntype/detect.go index b30851177..2240a118c 100644 --- a/internal/router/turntype/detect.go +++ b/internal/router/turntype/detect.go @@ -26,22 +26,17 @@ const ( // Classifier: short-form classification call (security monitor, etc.). // Hard-pinned AND skips session-pin creation. Classifier TurnType = "classifier" - // HarnessMeta: main-turn skill/command invocation referencing harness - // primitives (plan-mode tools, deferred-tool discovery, etc.). The proxy - // clamps these to a strong Claude-family model so the harness control plane - // cannot be silently downgraded to a non-Anthropic upstream that - // hallucinates harness primitives. + // HarnessMeta: main-turn skill/command invocation referencing harness primitives + // (plan-mode tools, deferred-tool discovery, etc.). Clamped to a strong Claude-family + // model — a non-Anthropic upstream that hallucinates harness primitives corrupts client state. HarnessMeta TurnType = "harness_meta" - // SubAgentHarnessMeta: sub-agent dispatch whose dispatch prompt references - // harness primitives. Same clamp as HarnessMeta, scoped to sub-agent turns - // so a parent conversation routing up does not accidentally leak the - // hard-pin into the orchestrated sub-agent path. + // SubAgentHarnessMeta: sub-agent dispatch whose prompt references harness primitives. + // Same clamp as HarnessMeta; scoped to sub-agent turns so escalation doesn't bleed + // into the orchestrated sub-agent path. SubAgentHarnessMeta TurnType = "sub_agent_harness_meta" - // Recovery: tool-result turn recovering from a deferred-tool - // InputValidationError. The previous turn's tool call failed for a - // harness-shape reason rather than an ordinary schema mistake; routing up - // here both retries against a model that knows the deferred-tool protocol - // and prevents a thrash loop against a weak upstream. + // Recovery: tool-result turn recovering from a deferred-tool InputValidationError. + // Previous tool call failed for a harness reason (not a schema mistake); routing + // up retries against a model that knows the deferred-tool protocol. Recovery TurnType = "recovery" ) @@ -86,12 +81,9 @@ func DetectFromEnvelope(env *translate.RequestEnvelope, feats translate.RoutingF if isClassifier(feats) { return Classifier } - // Recovery before harness-meta: both look at the last user message, but a - // tool_result turn must NEVER classify as HarnessMeta — its underlying - // shape is a continuation, not a skill invocation, so the only escalation - // it can earn is Recovery. The explicit LastKind guard below enforces that - // even when a tool_result turn's text happens to contain - // command markers. + // Recovery before harness-meta: a tool_result turn must NEVER classify as HarnessMeta + // (its underlying shape is a continuation). The explicit LastKind guard below enforces + // this even when a tool_result turn's contains command markers. if env.SourceFormat() == translate.FormatAnthropic && isRecoveryTurn(env, feats) { return Recovery } diff --git a/internal/router/turntype/harness.go b/internal/router/turntype/harness.go index 3e7f9acb7..7d40ea4b2 100644 --- a/internal/router/turntype/harness.go +++ b/internal/router/turntype/harness.go @@ -6,10 +6,8 @@ import ( "workweave/router/internal/translate" ) -// Bounded prefixes the harness-meta sniffers scan before giving up. A real -// Claude Code command dispatch keeps the skill/command markers + the harness -// reference well within the first few KB; binding the scan protects against -// pathological-but-templated requests padding out the prefix. +// Scan caps for harness-meta sniffers. Real Claude Code command dispatches keep +// skill/command markers + harness references well within the first few KB. const ( harnessMetaMainTurnScanMaxBytes = 16384 harnessMetaSubAgentScanMaxBytes = 4096 @@ -97,10 +95,8 @@ func isWordRune(r rune) bool { } // isHarnessMetaSubAgent reports whether the first user text of a sub-agent -// dispatch prompt references harness primitives. Bounded by -// harnessMetaSubAgentScanMaxBytes so we never walk the entirety of a -// long dispatch prompt — the harness signal always sits in the first KB -// of the dispatched prompt (CC's Agent tool template). +// dispatch prompt references harness primitives. Bounded by harnessMetaSubAgentScanMaxBytes; +// the harness signal always appears in the first KB of the Agent tool template. func isHarnessMetaSubAgent(firstUserText string) bool { if firstUserText == "" { return false From 05edf82a29fcebe41be7f641e7cb1142ad8485bc Mon Sep 17 00:00:00 2001 From: Steven Tohme Date: Tue, 18 Aug 2026 19:08:25 +0000 Subject: [PATCH 5/8] style(routing): shorten remaining review comments --- internal/proxy/claimed_tool_unavailable.go | 3 +-- internal/proxy/harness_escalation.go | 5 ++--- internal/translate/claudecode_tool_filter.go | 6 ++---- internal/translate/lastuser_toolresult.go | 9 ++++----- 4 files changed, 9 insertions(+), 14 deletions(-) diff --git a/internal/proxy/claimed_tool_unavailable.go b/internal/proxy/claimed_tool_unavailable.go index 97a33183c..fd71a0d39 100644 --- a/internal/proxy/claimed_tool_unavailable.go +++ b/internal/proxy/claimed_tool_unavailable.go @@ -44,8 +44,7 @@ const ( // claimedToolFiredCacheSize bounds the per-replica dedup LRU. claimedToolFiredCacheSize = 4096 // claimedToolFiredCacheTTL is how long a fired (session, role, tool) - // stays suppressed on this replica. The durable once-per-event budget is - // cheap; this only trims repeat rows for very long sessions. + // stays suppressed on this replica; the DB row is the durable record. claimedToolFiredCacheTTL = 24 * time.Hour // claimedToolWindowBytes is the lowercased window scanned around each // declared tool-name occurrence. Long enough to span "There is no ... tool diff --git a/internal/proxy/harness_escalation.go b/internal/proxy/harness_escalation.go index b676beacb..8aa333229 100644 --- a/internal/proxy/harness_escalation.go +++ b/internal/proxy/harness_escalation.go @@ -14,9 +14,8 @@ import ( "workweave/router/internal/translate" ) -// Harness-escalation action taxonomy, recorded per clamp attempt. Exactly one -// applies. Mirrors the loop-escalation action-taxonomy comment style -// (loop_detection.go): each name records why the clamp did or did not engage. +// Harness-escalation action taxonomy. Exactly one value applies per clamp +// attempt; each records why the clamp engaged or was skipped. const ( // harnessActionEscalated: the decision was replaced with escalateModel. harnessActionEscalated = "escalated" diff --git a/internal/translate/claudecode_tool_filter.go b/internal/translate/claudecode_tool_filter.go index cb1c28a61..e69ae6ac6 100644 --- a/internal/translate/claudecode_tool_filter.go +++ b/internal/translate/claudecode_tool_filter.go @@ -74,10 +74,8 @@ func isClaudeCodeOnlyTool(name string) bool { } // ClaudeCodeOnlyToolNames returns a sorted copy of the Claude-Code-only tool -// names. Exported for the turntype package's harness-reference gate, which -// scans prompt text for these names to detect harness-protocol-bound turns; -// sharing the list keeps the gate in sync with the emit-path filter instead -// of duplicating the name set. Sorted for deterministic iteration. +// names. Exported so the turntype harness-reference gate can scan for these +// names without duplicating the set. Sorted for deterministic iteration. func ClaudeCodeOnlyToolNames() []string { names := make([]string, 0, len(claudeCodeOnlyToolNames)) for name := range claudeCodeOnlyToolNames { diff --git a/internal/translate/lastuser_toolresult.go b/internal/translate/lastuser_toolresult.go index 56ec9df77..8b471c740 100644 --- a/internal/translate/lastuser_toolresult.go +++ b/internal/translate/lastuser_toolresult.go @@ -7,11 +7,10 @@ import ( ) // LastUserToolResultErrorText returns the concatenated text of the last user -// message's errored (is_error: true) tool_result blocks, truncated to -// maxBytes. Anthropic format only (OpenAI/Gemini carry no equivalent is_error marker). -// Exists separately from LastUserMessageInfo because the turntype harness gate -// needs the failure text to distinguish a deferred-tool InputValidationError -// from an ordinary schema mistake. maxBytes <= 0 returns "". +// message's errored (is_error: true) tool_result blocks, truncated to maxBytes. +// Anthropic format only; exists separately from LastUserMessageInfo so the +// harness gate can distinguish a deferred-tool InputValidationError from an +// ordinary schema mistake. Returns "" when maxBytes <= 0. func (e *RequestEnvelope) LastUserToolResultErrorText(maxBytes int) string { if e.format != FormatAnthropic || maxBytes <= 0 { return "" From 8ae49d6ad09213f4a8b2ae107e8a47aa9f114c3f Mon Sep 17 00:00:00 2001 From: Steven Tohme Date: Tue, 18 Aug 2026 19:14:36 +0000 Subject: [PATCH 6/8] style(routing): shorten round-four review comments --- internal/proxy/service.go | 6 ++---- internal/proxy/turnloop.go | 13 +++++-------- internal/router/catalog/family.go | 2 -- internal/router/turntype/harness.go | 10 ++++------ 4 files changed, 11 insertions(+), 20 deletions(-) diff --git a/internal/proxy/service.go b/internal/proxy/service.go index 94baf7e83..a7daa7705 100644 --- a/internal/proxy/service.go +++ b/internal/proxy/service.go @@ -177,10 +177,8 @@ type Service struct { // (action=disabled) but writes no escalation pin. Defaults true. loopEscalationEnabled bool // harnessEscalationEnabled is the kill switch for the harness-protocol - // escalate clamp (route harness-bound turns up, never down). False keeps - // the clamp from engaging (action=disabled) while harness turn-type - // detection and routing continue unchanged. Defaults true; knob is - // ROUTER_HARNESS_ESCALATION_ENABLED. + // escalation clamp. False keeps detection/telemetry running (action=disabled) + // but skips the clamp. Defaults true; knob: ROUTER_HARNESS_ESCALATION_ENABLED. harnessEscalationEnabled bool // loopEscalationHoldoutPct is the percentage of loop-detected sessions // deterministically assigned to a log-not-act holdout, so the self-recovery diff --git a/internal/proxy/turnloop.go b/internal/proxy/turnloop.go index 5d10e20a8..0ec5be69a 100644 --- a/internal/proxy/turnloop.go +++ b/internal/proxy/turnloop.go @@ -311,10 +311,9 @@ func (s *Service) isHardPinnedTurn(ctx context.Context, tt turntype.TurnType) bo } // authoritativePolicyTurn reports whether tt is a model-authoritative policy -// turn. Compared on tt.Base() so the harness variants (HarnessMeta → -// MainLoop, Recovery → ToolResult) keep the authoritative-policy behavior of -// their underlying shape, while SubAgentHarnessMeta (→ SubAgentDispatch) stays -// out. +// turn. Uses tt.Base() so the harness variants (HarnessMeta → MainLoop, +// Recovery → ToolResult) keep the authoritative-policy behavior of their +// underlying shape. func authoritativePolicyTurn(tt turntype.TurnType) bool { return tt.Base() == turntype.MainLoop || tt.Base() == turntype.ToolResult } @@ -362,10 +361,8 @@ func forcedPinEligible(pin sessionpin.Pin, req router.Request) bool { return ok } -// runTurnLoop is the format-agnostic routing orchestrator. It is the single -// choke point every decision path passes through exactly once: after the -// routing decision resolves it applies the harness-protocol escalation clamp -// (route harness-bound turns up, never down), then returns the result. +// runTurnLoop wraps runTurnLoopInner with the harness-protocol escalation +// clamp (route harness-bound turns up, never down). func (s *Service) runTurnLoop( ctx context.Context, env *translate.RequestEnvelope, diff --git a/internal/router/catalog/family.go b/internal/router/catalog/family.go index 53fdca1f2..270186644 100644 --- a/internal/router/catalog/family.go +++ b/internal/router/catalog/family.go @@ -15,8 +15,6 @@ var familyVersionPattern = regexp.MustCompile(`^(.+?)(\d+)(?:[.-](\d+))?(-[a-z][ // IsClaudeFamily reports whether id's family is a Claude family (family name // starts with "claude"). Unknown/ungenerational ids (gpt-4o, "") are false. -// Used by the harness-protocol escalation clamp to test whether a resolved -// decision already sits on a strong Claude-family model. func IsClaudeFamily(id string) bool { family, _, ok := FamilyAndVersion(id) if !ok { diff --git a/internal/router/turntype/harness.go b/internal/router/turntype/harness.go index 7d40ea4b2..0430e3270 100644 --- a/internal/router/turntype/harness.go +++ b/internal/router/turntype/harness.go @@ -130,10 +130,9 @@ func isHarnessMetaMainTurn(env *translate.RequestEnvelope) bool { } // isRecoveryTurn reports whether this tool_result turn is recovering from a -// deferred-tool InputValidationError. Requires both "InputValidationError" in -// the errored payload AND a deferred/harness-primitive reference — the AND -// prevents ordinary schema-mistake retries (wrong Bash param type) from being -// misclassified as harness control-plane failures. +// deferred-tool InputValidationError. Requires BOTH the error text AND a +// harness-primitive reference — preventing ordinary schema-mistake retries +// from being routed up to opus. func isRecoveryTurn(env *translate.RequestEnvelope, feats translate.RoutingFeatures) bool { if env == nil { return false @@ -153,8 +152,7 @@ func isRecoveryTurn(env *translate.RequestEnvelope, feats translate.RoutingFeatu // hasDeferredToolContext reports whether the errored text references a // deferred-tool context: literally the substring "deferred" (any case) OR -// any CC-only tool name. Same case-sensitivity rules as -// referencesHarnessPrimitives for the tool-name sweep. +// any CC-only tool name via referencesHarnessPrimitives. func hasDeferredToolContext(text string) bool { if strings.Contains(strings.ToLower(text), "deferred") { return true From b521c8d1d1a986bef83d6ddd4e2885b417fb3d16 Mon Sep 17 00:00:00 2001 From: Steven Tohme Date: Tue, 18 Aug 2026 19:19:37 +0000 Subject: [PATCH 7/8] style(routing): shorten round-five review comments --- internal/proxy/claimed_tool_unavailable.go | 10 ++++------ internal/router/turntype/harness.go | 7 +++---- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/internal/proxy/claimed_tool_unavailable.go b/internal/proxy/claimed_tool_unavailable.go index fd71a0d39..a4fb10b2a 100644 --- a/internal/proxy/claimed_tool_unavailable.go +++ b/internal/proxy/claimed_tool_unavailable.go @@ -204,16 +204,14 @@ func rightIdentifierEdge(s string, i int) bool { // isIdentifierClass is the identifier-boundary class: ASCII letter, digit, or // underscore. Bytes >= 0x80 count as boundaries (a surrounding non-ASCII -// character must never break a match), and single-byte reads avoid any -// partial-rune decoding on multibyte text. +// character must never break a match). func isIdentifierClass(b byte) bool { return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_' } -// windowClaimsUnavailable checks the "no tool" precede signal (a "no " -// or "there is no " ending within claimedToolNoPrecedeBytes before the match) -// and the lowercased ±claimedToolWindowBytes window around the occurrence for -// any claimedUnavailablePhrases entry. +// windowClaimsUnavailable checks a ±claimedToolWindowBytes window around +// each occurrence for unavailable-claim phrases, plus the "no " precede +// signal via endsWithNoPhrase. func windowClaimsUnavailable(lower string, occStart, occEnd int) bool { // The direct precedent: "...no " / "...there is no " ending immediately // before the name. Covers "no tool" without needing a window phrase. diff --git a/internal/router/turntype/harness.go b/internal/router/turntype/harness.go index 0430e3270..2d505e18c 100644 --- a/internal/router/turntype/harness.go +++ b/internal/router/turntype/harness.go @@ -13,10 +13,9 @@ const ( harnessMetaSubAgentScanMaxBytes = 4096 ) -// Harness-reference keyword gate (case-insensitive). Phrases cover -// human-language harness control-plane invocations; CC-only tool names -// (case-sensitive word boundaries) layer on top so a dispatch like -// "Load EnterPlanMode tool schema" is caught even without the prose phrases. +// Harness-reference keyword gate (case-insensitive). Prose phrases match +// human-language control-plane invocations; CC-only tool names (case-sensitive +// word boundaries) add a second layer for dispatch-style prompts. var harnessKeywordPhrases = []string{"plan mode", "tool schema", "deferred tool"} // harnessMetaCCToolScanNames is the CC-only tool-name set filtered to names From 672a2ef9eb2d49e27bfc4df1c6d4998e094a6cb8 Mon Sep 17 00:00:00 2001 From: Steven Tohme Date: Tue, 18 Aug 2026 19:24:23 +0000 Subject: [PATCH 8/8] style(routing): shorten round-six review comments --- internal/proxy/claimed_tool_unavailable.go | 10 ++++------ internal/proxy/turnloop.go | 8 +++----- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/internal/proxy/claimed_tool_unavailable.go b/internal/proxy/claimed_tool_unavailable.go index a4fb10b2a..9aab8e9f4 100644 --- a/internal/proxy/claimed_tool_unavailable.go +++ b/internal/proxy/claimed_tool_unavailable.go @@ -47,9 +47,8 @@ const ( // stays suppressed on this replica; the DB row is the durable record. claimedToolFiredCacheTTL = 24 * time.Hour // claimedToolWindowBytes is the lowercased window scanned around each - // declared tool-name occurrence. Long enough to span "There is no ... tool - // in my available toolset"-style sentences, short enough to not match a - // "not available" elsewhere in a long reply. + // declared tool-name occurrence — wide enough to catch multi-clause denials + // without matching stray "not available" elsewhere in a long reply. claimedToolWindowBytes = 160 // claimedToolNoPrecedeBytes is how far before an occurrence "no " / // "there is no " (the "no tool" signal) is checked. @@ -304,9 +303,8 @@ func (s *Service) maybeReportClaimedToolUnavailable( RequestID: requestID, RouteID: routeID, } - // context.Background(): the request ctx may already be canceled by the - // time this runs post-stream; losing the row would drop the failing - // turn from the auto corpus. + // context.Background(): the request ctx may be canceled post-stream; + // a canceled ctx would silently drop the row from the auto corpus. if err := s.feedbackStore.InsertRouterFeedback(context.Background(), event); err != nil { log.Error("router.claimed_tool_unavailable: feedback insert failed", "err", err) continue // leave the LRU unset so the next turn retries diff --git a/internal/proxy/turnloop.go b/internal/proxy/turnloop.go index 0ec5be69a..bee23e5b7 100644 --- a/internal/proxy/turnloop.go +++ b/internal/proxy/turnloop.go @@ -302,8 +302,7 @@ func (s *Service) isHardPinnedTurn(ctx context.Context, tt turntype.TurnType) bo return s.hardPinExplore || s.hasSubAgentOverride() case turntype.SubAgentHarnessMeta: // Never hard-pinned: the harness escalation clamp routes sub-agent - // harness turns UP (its job), overriding any low-tier background pin a - // hard pin here would impose. + // harness turns UP, overriding any low-tier background pin. return false default: return false @@ -311,9 +310,8 @@ func (s *Service) isHardPinnedTurn(ctx context.Context, tt turntype.TurnType) bo } // authoritativePolicyTurn reports whether tt is a model-authoritative policy -// turn. Uses tt.Base() so the harness variants (HarnessMeta → MainLoop, -// Recovery → ToolResult) keep the authoritative-policy behavior of their -// underlying shape. +// turn. Uses tt.Base() so harness variants (HarnessMeta → MainLoop, +// Recovery → ToolResult) inherit the policy behavior of their base shape. func authoritativePolicyTurn(tt turntype.TurnType) bool { return tt.Base() == turntype.MainLoop || tt.Base() == turntype.ToolResult }