From b99ac97a249421f1c2e000e59d52f88388bbb877 Mon Sep 17 00:00:00 2001 From: Henry Date: Thu, 17 Sep 2026 21:33:58 +0800 Subject: [PATCH] fix(dynamic-routing): stop scrambling and truncating dynamic-agent launch prompts (#3741) Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio --- .../agent/runtime/dynamic/conductor.go | 10 +- .../agent/runtime/dynamic/conductor_test.go | 64 ++++++++++ .../agent/runtime/routingerr/sanitize.go | 9 ++ .../agent/runtime/routingerr/sanitize_test.go | 78 ++++++++++++ .../plan.md | 109 +++++++++++++++++ ...-sanitize-launch-prompt-credential-tier.md | 112 ++++++++++++++++++ .../requirements/provider-error-recovery.md | 3 +- .../provider-error-recovery-04.md | 46 ++++++- .../system-design/provider-error-recovery.md | 2 +- 9 files changed, 422 insertions(+), 11 deletions(-) create mode 100644 docs/plans/sanitize-launch-prompt-credential-tier/plan.md create mode 100644 docs/plans/sanitize-launch-prompt-credential-tier/task-01-sanitize-launch-prompt-credential-tier.md diff --git a/apps/backend/internal/agent/runtime/dynamic/conductor.go b/apps/backend/internal/agent/runtime/dynamic/conductor.go index 538159d8f5..a199a428f5 100644 --- a/apps/backend/internal/agent/runtime/dynamic/conductor.go +++ b/apps/backend/internal/agent/runtime/dynamic/conductor.go @@ -661,11 +661,13 @@ func boundedConversation(userMessages []string, conversation string) string { } } -// ContinuationPrompt renders a bounded, provider-neutral handoff. The -// original prompt remains first so providers that do not understand the -// optional package still receive the user's request. +// ContinuationPrompt renders a provider-neutral handoff. The original prompt +// remains first, credential-redacted but otherwise verbatim and unbounded, +// so providers that do not understand the optional package still receive +// the user's request whole; the continuation package appended after it is +// bounded. func ContinuationPrompt(prompt string, continuation Continuation) string { - prompt = strings.TrimSpace(routingerr.Sanitize(prompt)) + prompt = strings.TrimSpace(routingerr.SanitizeCredentialsUnbounded(prompt)) continuation = sanitizeContinuation(continuation) fields := make([]string, 0, 7) if continuation.TaskDescription != "" { diff --git a/apps/backend/internal/agent/runtime/dynamic/conductor_test.go b/apps/backend/internal/agent/runtime/dynamic/conductor_test.go index 432bf37691..e8ceeea881 100644 --- a/apps/backend/internal/agent/runtime/dynamic/conductor_test.go +++ b/apps/backend/internal/agent/runtime/dynamic/conductor_test.go @@ -126,6 +126,70 @@ func TestConductorSanitizesPromptBeforeEveryDownstreamLaunch(t *testing.T) { } } +// TestConductorPreservesLongUserAuthoredPromptOnEveryDownstreamLaunch proves +// the primary launch prompt receives credential-only redaction, unbounded, +// on both the first attempt and every fallback: a path, a commit SHA, a PR +// URL and a task UUID all survive intact even past MaxRawExcerptBytes, while +// a credential is still redacted. +func TestConductorPreservesLongUserAuthoredPromptOnEveryDownstreamLaunch(t *testing.T) { + profile := Profile{ + ID: "dynamic-profile", + Candidates: []Candidate{ + { + ID: "candidate-first", + Enabled: true, + BindingKey: "first", + Rules: map[string]Action{string(routingerr.CodeProviderUnavailable): ActionTryNext}, + }, + {ID: "candidate-second", Enabled: true, BindingKey: "second"}, + }, + } + const secret = "sk-abcdEFGH12345678ijklMNOPqrstUVWX" + const path = "/Users/alice/work/repo/main.go" + const sha = "94e7b02458b6c1a2d3e4f5061728394a5b6c7d8" + const url = "https://example.com/org/repo/pull/123?tab=files#discussion_r42" + const uuid = "3f9a1c2e-4b5d-4e6f-8a9b-0c1d2e3f4a5b" + filler := strings.Repeat("Then update the handler so it returns a typed error. ", 100) + prompt := filler + "Fix bug in " + path + " at commit " + sha + "; see " + url + + " and task " + uuid + ".\n API_KEY=" + secret + + if len(prompt) <= routingerr.MaxRawExcerptBytes { + t.Fatalf("test prompt must exceed MaxRawExcerptBytes=%d, got %d", routingerr.MaxRawExcerptBytes, len(prompt)) + } + + downstream := &conductorTestDownstream{} + conductor := NewConductor(NewEngine(), conductorTestProfileLoader{profile: profile}, downstream) + + _, err := conductor.Launch(context.Background(), ConductorLaunch{ + SessionID: "session-long-prompt", + LogicalProfileID: profile.ID, + Prompt: prompt, + PriorACPSession: "acp-first", + }) + if err != nil { + t.Fatalf("Launch: %v", err) + } + if len(downstream.launches) != 2 { + t.Fatalf("launch count = %d, want 2", len(downstream.launches)) + } + for i, launch := range downstream.launches { + if !strings.HasPrefix(launch.Prompt, "Then update the handler") { + t.Fatalf("launch %d prompt did not survive whole: %q", i, launch.Prompt) + } + for _, want := range []string{path, sha, url, uuid} { + if !strings.Contains(launch.Prompt, want) { + t.Fatalf("launch %d prompt lost %q: %q", i, want, launch.Prompt) + } + } + if strings.Contains(launch.Prompt, secret) { + t.Fatalf("launch %d prompt retained the raw secret: %q", i, launch.Prompt) + } + if !strings.Contains(launch.Prompt, "API_KEY: ***") { + t.Fatalf("launch %d prompt was not redacted, just dropped: %q", i, launch.Prompt) + } + } +} + type recordingConductorTestDownstream struct { launches []DownstreamLaunch } diff --git a/apps/backend/internal/agent/runtime/routingerr/sanitize.go b/apps/backend/internal/agent/runtime/routingerr/sanitize.go index b54a489f15..3c943d3038 100644 --- a/apps/backend/internal/agent/runtime/routingerr/sanitize.go +++ b/apps/backend/internal/agent/runtime/routingerr/sanitize.go @@ -411,6 +411,15 @@ func SanitizeCredentials(s string) string { return applyRedactions(s, credentialRedactions) } +// SanitizeCredentialsUnbounded applies the same rule set as +// SanitizeCredentials but without the MaxRawExcerptBytes output cap. Use +// this for user-authored text that must reach its destination whole, such +// as a task's primary launch prompt, where truncation would silently drop +// instructions rather than only redact credentials. +func SanitizeCredentialsUnbounded(s string) string { + return applyRedactionsUnbounded(s, credentialRedactions) +} + // SanitizeFullUnbounded applies the same rule set as Sanitize but without the // MaxRawExcerptBytes output cap. Use this only when the caller will apply its // own tail truncation afterward (see dynamic.sanitizedTail): sanitizing diff --git a/apps/backend/internal/agent/runtime/routingerr/sanitize_test.go b/apps/backend/internal/agent/runtime/routingerr/sanitize_test.go index 8f8b644e46..34d4d77976 100644 --- a/apps/backend/internal/agent/runtime/routingerr/sanitize_test.go +++ b/apps/backend/internal/agent/runtime/routingerr/sanitize_test.go @@ -631,6 +631,84 @@ func TestSanitizeCredentials_IdempotentFuzz(t *testing.T) { } } +// TestSanitizeCredentialsUnbounded_PreservesLengthAndContent proves the +// unbounded credential tier used for the primary launch prompt neither +// truncates nor mangles non-credential content, on an input well past +// MaxRawExcerptBytes. +func TestSanitizeCredentialsUnbounded_PreservesLengthAndContent(t *testing.T) { + body := strings.Repeat( + "Fix bug in /Users/alice/work/repo/main.go at commit "+ + "94e7b02458b6c1a2d3e4f5061728394a5b6c7d8; see "+ + "https://example.com/org/repo/pull/123?tab=files#discussion_r42 "+ + "and task 3f9a1c2e-4b5d-4e6f-8a9b-0c1d2e3f4a5b. ", + 100, + ) + if len(body) <= MaxRawExcerptBytes { + t.Fatalf("test input must exceed MaxRawExcerptBytes=%d, got %d", MaxRawExcerptBytes, len(body)) + } + + got := SanitizeCredentialsUnbounded(body) + + if len(got) != len(body) { + t.Fatalf("expected unbounded output length %d, got %d", len(body), len(got)) + } + if got != body { + t.Fatalf("expected non-credential content to survive unchanged") + } + for _, want := range []string{ + "/Users/alice/work/repo/main.go", + "94e7b02458b6c1a2d3e4f5061728394a5b6c7d8", + "https://example.com/org/repo/pull/123?tab=files#discussion_r42", + "3f9a1c2e-4b5d-4e6f-8a9b-0c1d2e3f4a5b", + } { + if !strings.Contains(got, want) { + t.Fatalf("expected output to retain %q, got %q", want, got) + } + } +} + +// TestSanitizeCredentialsUnbounded_RedactsCredentials proves the unbounded +// credential tier still redacts credential-shaped patterns, matching +// SanitizeCredentials' behavior minus truncation. +func TestSanitizeCredentialsUnbounded_RedactsCredentials(t *testing.T) { + cases := []struct { + name string + in string + mustNotHave string + mustHave string + }{ + { + name: "anthropic-style key", + in: "use sk-abcdef1234567890QQQQ to call", + mustNotHave: "sk-abcdef1234567890", + mustHave: "sk-***", + }, + { + name: "github classic pat", + in: "token ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA leaks", + mustNotHave: "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + mustHave: "ghp_***", + }, + { + name: "api key assignment", + in: "API_KEY=sk-abcdEFGH12345678ijklMNOPqrstUVWX in the env", + mustNotHave: "sk-abcdEFGH12345678ijklMNOPqrstUVWX", + mustHave: "API_KEY: ***", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := SanitizeCredentialsUnbounded(c.in) + if strings.Contains(got, c.mustNotHave) { + t.Fatalf("expected %q to be redacted, got %q", c.mustNotHave, got) + } + if !strings.Contains(got, c.mustHave) { + t.Fatalf("expected %q in output, got %q", c.mustHave, got) + } + }) + } +} + // TestSanitize_TruncatesOnRuneBoundary proves Sanitize never splits a // multi-byte rune when cutting to MaxRawExcerptBytes. Vietnamese (3-byte) and // CJK (3-byte) runes are repeated at every byte alignment relative to diff --git a/docs/plans/sanitize-launch-prompt-credential-tier/plan.md b/docs/plans/sanitize-launch-prompt-credential-tier/plan.md new file mode 100644 index 0000000000..2142ac914c --- /dev/null +++ b/docs/plans/sanitize-launch-prompt-credential-tier/plan.md @@ -0,0 +1,109 @@ +--- +created: 2026-09-17 +status: done +requirements: + - REQ-PLATFORM-PROVIDER-ERROR-RECOVERY-001 +system_design: + - ../../specs/platform/system-design/provider-error-recovery-04.md +legacy_specs: [] +--- + +# Implementation Plan: Sanitize Launch Prompt With Credential Tier + +## Overview + +`Conductor.launchWithFallback` rendered the dynamic-agent primary launch +prompt through `ContinuationPrompt` on every downstream launch attempt, +including attempt 0. `ContinuationPrompt` ran that prompt through +`routingerr.Sanitize`, the diagnostic tier meant for provider output: it +collapses any 32-plus-character run to `***` (destroying file paths, commit +SHAs, PR URLs, and the task/session UUIDs the injected `` +block carries), rewrites URLs down to scheme and host, and truncates at +`MaxRawExcerptBytes` (4096 bytes). Because the task/session UUIDs are how a +fallback provider's MCP tool calls address the task, this broke the fallback +path it was meant to protect, and it silently mangled ordinary user-authored +prompt content on the very first launch attempt. + +This is a defect in already-shipped behavior (PR #3407 / commit `9dea4b175`), +not a new capability: fix the tier, not the shape of the feature. + +## Scope + +### In scope + +- Route the primary launch prompt through `routingerr.SanitizeCredentials`'s + rule set instead of `routingerr.Sanitize`. +- Add an unbounded variant so the primary prompt is not truncated at + `MaxRawExcerptBytes` (unlike the continuation package's bounded fields). +- Regression coverage proving a long user-authored prompt survives whole on + every launch attempt while embedded credentials are still redacted. +- Record the tier split in the provider-error-recovery system design and + requirements. + +### Out of scope + +- Hardening the redaction rule sets themselves (signed-URL query-credential + gap, tightened patterns) — tracked separately as `76e284d2`. +- `sanitizeContinuation` and the continuation package's `Conversation`, + `ToolSummary`, and `FailureReason` fields, which keep the diagnostic tier + unchanged. +- Any change to retry policy, provider selection, or fallback ordering. + +## Technical approach + +- `apps/backend/internal/agent/runtime/routingerr/sanitize.go`: add + `SanitizeCredentialsUnbounded`, calling the existing + `applyRedactionsUnbounded` helper with the existing `credentialRedactions` + rule set (no new rules). +- `apps/backend/internal/agent/runtime/dynamic/conductor.go`: swap the tier + `ContinuationPrompt` applies to the primary prompt from + `routingerr.Sanitize` to `routingerr.SanitizeCredentialsUnbounded`. +- `docs/specs/platform/system-design/provider-error-recovery-04.md`: extend + the existing continuation-package sanitization-tiers section to cover the + primary launch prompt. +- `docs/specs/platform/requirements/provider-error-recovery.md`: add + `AC-PLATFORM-PROVIDER-ERROR-RECOVERY-001.29` describing the credential-only, + unbounded tier for the primary launch prompt. + +## Tests + +- **What:** A prompt longer than `MaxRawExcerptBytes` survives whole (no + truncation, no path/SHA/URL/UUID scrambling) on every downstream launch + attempt, while `sk-`/`ghp_`/`API_KEY=`-shaped credentials in that same + prompt are still redacted. + **File:** + `apps/backend/internal/agent/runtime/dynamic/conductor_test.go` + (`TestConductorPreservesLongUserAuthoredPromptOnEveryDownstreamLaunch`). + **How:** Go table test asserting the rendered prompt at attempt 0 and a + fallback attempt. +- **What:** `SanitizeCredentialsUnbounded` preserves length/content and still + redacts credential shapes. + **File:** `apps/backend/internal/agent/runtime/routingerr/sanitize_test.go` + (`TestSanitizeCredentialsUnbounded_PreservesLengthAndContent`, + `TestSanitizeCredentialsUnbounded_RedactsCredentials`). + +## Work orders + +- [x] [Task 01: Sanitize launch prompt with credential tier](task-01-sanitize-launch-prompt-credential-tier.md) + +## Verification results + +``` +make -C apps/backend lint -> 0 issues. +make typecheck -> exit 0 +go test ./internal/agent/runtime/dynamic/... ./internal/agent/runtime/routingerr/... -> PASS +``` + +Mutation check: reverting the tier swap in `conductor.go` fails +`TestConductorPreservesLongUserAuthoredPromptOnEveryDownstreamLaunch` with +`launch 0 prompt lost "/Users/alice/work/repo/main.go"`. + +## Open questions + +The primary launch prompt is intentionally unbounded after credential +redaction. A future change must define a provider-neutral token or byte budget +for the complete composed prompt, including server-injected context, and an +explicit compaction or user-visible rejection path when that budget is +exceeded. The budget must come from provider capability metadata, not a +provider-name branch in the routing conductor. Until that contract exists, +provider context-window errors remain the downstream safety boundary. diff --git a/docs/plans/sanitize-launch-prompt-credential-tier/task-01-sanitize-launch-prompt-credential-tier.md b/docs/plans/sanitize-launch-prompt-credential-tier/task-01-sanitize-launch-prompt-credential-tier.md new file mode 100644 index 0000000000..264db1e3f1 --- /dev/null +++ b/docs/plans/sanitize-launch-prompt-credential-tier/task-01-sanitize-launch-prompt-credential-tier.md @@ -0,0 +1,112 @@ +--- +id: "01-sanitize-launch-prompt-credential-tier" +title: "Sanitize launch prompt with credential tier" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-PLATFORM-PROVIDER-ERROR-RECOVERY-001 +acceptance_criteria: + - AC-PLATFORM-PROVIDER-ERROR-RECOVERY-001.29 +system_design: + - ../../specs/platform/system-design/provider-error-recovery-04.md +--- + +# Task 01: Sanitize Launch Prompt With Credential Tier + +## Summary + +`Conductor.launchWithFallback` ran the primary dynamic-agent launch prompt +through the diagnostic-tier `routingerr.Sanitize` on every downstream launch +attempt, including attempt 0. Swap it for the credential-only tier, applied +without truncation, so the prompt is never scrambled or cut for a reason that +only makes sense for provider output. + +## In scope + +- New `routingerr.SanitizeCredentialsUnbounded` (existing credential rule set, + no byte cap). +- One-line tier swap in `ContinuationPrompt`'s caller in `conductor.go`. +- Regression tests proving a long prompt survives whole while credentials are + still redacted, on attempt 0 and a fallback attempt. +- Requirements/system-design updates recording the tier split. + +## Out of scope + +- Redaction rule-set hardening (signed-URL query-credential gap) — `76e284d2`. +- `sanitizeContinuation` and the continuation package's diagnostic-tier + fields (`Conversation`, `ToolSummary`, `FailureReason`). +- Retry policy, provider selection, or fallback ordering. + +## Acceptance + +- A prompt longer than `MaxRawExcerptBytes` (4096 bytes) is rendered whole, + with no path/SHA/URL/UUID collapsed to `***`, on every downstream launch + attempt. +- A `sk-`/`ghp_`/`API_KEY=`-shaped credential embedded in that same prompt is + still redacted on every attempt. +- `sanitizeContinuation` and `FailureReason` keep the diagnostic tier + unchanged. + +## Verification + +```bash +(cd apps/backend && go test ./internal/agent/runtime/dynamic/... -run TestConductorPreservesLongUserAuthoredPromptOnEveryDownstreamLaunch -v) +(cd apps/backend && go test ./internal/agent/runtime/routingerr/... -run TestSanitizeCredentialsUnbounded -v) +``` + +The provider-aware prompt-size budget is a deferred follow-up. This PR keeps +the primary prompt unbounded because the conductor does not own provider +context-window capacity or an overflow user experience. + +## Files likely touched + +- `apps/backend/internal/agent/runtime/routingerr/sanitize.go` +- `apps/backend/internal/agent/runtime/routingerr/sanitize_test.go` +- `apps/backend/internal/agent/runtime/dynamic/conductor.go` +- `apps/backend/internal/agent/runtime/dynamic/conductor_test.go` +- `docs/specs/platform/system-design/provider-error-recovery-04.md` +- `docs/specs/platform/requirements/provider-error-recovery.md` + +## Dependencies + +None. + +## Risks + +- Widening the tier the wrong direction (diagnostic instead of credential) + would repeat the original defect for a different field. +- Reusing the bounded `SanitizeCredentials` entry point instead of an + unbounded variant would keep truncating the primary prompt. +- The complete composed prompt has no provider-aware size budget yet. Adding + an arbitrary byte cap here could silently remove instructions or identifiers. + Define the budget and its overflow behavior in a separate contract change. + +## Parallelism + +`sequential` + +## Inputs + +- PR #3393 review finding (deferred to this card) and the accepted D10 gap in + `provider-error-recovery.md`. +- Live-deployment probe of `routingerr.Sanitize` against a representative + prompt (paths, a commit SHA, a PR URL, a task UUID), confirming the + truncation and scrambling. + +## Results + +Added `SanitizeCredentialsUnbounded` in `sanitize.go` and swapped +`conductor.go`'s primary-prompt call from `routingerr.Sanitize` to it. Added +`TestConductorPreservesLongUserAuthoredPromptOnEveryDownstreamLaunch` and two +`SanitizeCredentialsUnbounded` tests. Reviewed through 4 rounds (Kandev +code-review, cross-vendor Codex, security) with no findings; mutation-tested +by reverting the tier swap. + +Verified with: + +```bash +(cd apps/backend && go test ./internal/agent/runtime/dynamic/... -run TestConductorPreservesLongUserAuthoredPromptOnEveryDownstreamLaunch -v -count=1) +(cd apps/backend && go test ./internal/agent/runtime/routingerr/... -run TestSanitizeCredentialsUnbounded -v -count=1) +``` diff --git a/docs/specs/platform/requirements/provider-error-recovery.md b/docs/specs/platform/requirements/provider-error-recovery.md index 6c249e3143..7998220709 100644 --- a/docs/specs/platform/requirements/provider-error-recovery.md +++ b/docs/specs/platform/requirements/provider-error-recovery.md @@ -2,7 +2,7 @@ status: draft system: platform created: 2026-08-08 -updated: 2026-09-16 +updated: 2026-09-17 owners: - Kandev --- @@ -62,6 +62,7 @@ duplicate transcript rows or stale recovery context. - **AC-PLATFORM-PROVIDER-ERROR-RECOVERY-001.26:** When a supported adapter receives exact structured evidence that the active provider abandoned the current response attempt and will retry it internally, Kandev shall accept a response-attempt retry boundary within that prompt generation. One prompt generation may contain multiple sequential boundaries; each boundary applies only to assistant and thinking records created after the preceding boundary or commit boundary. Malformed evidence, a false retry flag, an unsupported adapter, a zero or stale prompt generation, and terminal provider errors shall not create that boundary or remove transcript content. - **AC-PLATFORM-PROVIDER-ERROR-RECOVERY-001.27:** After Kandev accepts a response-attempt retry boundary, it shall durably remove assistant and thinking messages created by the abandoned attempt. Connected desktop and mobile chats shall remove those messages before later replacement output, and a reload or another viewer shall see only the replacement response. - **AC-PLATFORM-PROVIDER-ERROR-RECOVERY-001.28:** Response-attempt cleanup shall preserve messages, tool activity, permission state, and turns committed before the active provider attempt. A repeated retry boundary with no new output shall be a no-op, and abandoned assistant text that has not crossed a history boundary shall not enter later fallback resume context. The boundary shall not erase prompt-level output or effect evidence used to decide whether a separate Kandev-owned replay is safe. +- **AC-PLATFORM-PROVIDER-ERROR-RECOVERY-001.29:** The primary launch prompt shall receive credential-only, unbounded redaction every attempt including attempt 0, never the diagnostic tier reserved for provider output and `FailureReason`. ## Out of scope diff --git a/docs/specs/platform/system-design/provider-error-recovery-04.md b/docs/specs/platform/system-design/provider-error-recovery-04.md index 3318b1c52a..bac07ef519 100644 --- a/docs/specs/platform/system-design/provider-error-recovery-04.md +++ b/docs/specs/platform/system-design/provider-error-recovery-04.md @@ -4,7 +4,7 @@ system: platform requirements: - REQ-PLATFORM-PROVIDER-ERROR-RECOVERY-001 created: 2026-09-11 -updated: 2026-09-11 +updated: 2026-09-17 owners: - Kandev --- @@ -13,10 +13,11 @@ owners: ## Purpose and boundaries Part 1 defines the dynamic-routing continuation package's data model -(`## Data model`). This part carries one subsection relocated from Part 1 -verbatim, unchanged, when Part 1 reached its file-size limit: the two -sanitization tiers `routingerr` applies to the continuation package's carrier -text before persistence. +(`## Data model`). This part carries one subsection relocated from Part 1 when +Part 1 reached its file-size limit, and extended since: the sanitization +tiers `routingerr` applies to the continuation package's carrier text before +persistence, and to the primary launch prompt `ContinuationPrompt` renders +ahead of that package at each downstream launch. It does not change classification rules, policy values, retry ownership, or candidate ordering. @@ -67,6 +68,41 @@ scanner: a credential shaped like something not on that list (a vendor-specific token prefix, for example) survives the narrow tier unless it also matches a listed pattern. +The primary launch prompt (`ConductorLaunch.Prompt`, rendered by +`ContinuationPrompt` ahead of the continuation package on every attempt, +including attempt 0, not only fallbacks) is the composed first-turn prompt +built by `orchestrator.Service` (`task_operations.go`): the user's own text +plus server-injected context — the `` block carrying the +task/session IDs and the MCP tool list, and, for config-mode sessions, +injected config context. `Message.ToAPI` strips the `` block +before it reaches the UI bubble, so this is not simply text already shown to +the user unredacted. It carries the same kind of long identifiers as +`TaskDescription`/`PlanSummary`/`RepositorySummary` above (a file path, a +commit SHA, a PR URL, a task UUID) plus the task/session UUIDs the +`` block itself injects, so it receives the same narrow +credential-only tier, via `routingerr.SanitizeCredentialsUnbounded` rather +than `SanitizeCredentials` — unbounded, because unlike the continuation +fields it is not subject to `continuationFieldLimit` and must not be +silently truncated. This is required for correctness, not only fidelity: the +diagnostic tier's 32-plus-character catch-all would mangle the injected +task/session UUIDs and break the fallback provider's MCP tool calls, which +address the task by that UUID. Diagnostic-tier redaction (`routingerr.Sanitize`) +is reserved for provider output and `FailureReason`, never for this field. + +#### Deferred provider prompt budget + +This contract intentionally does not set a byte cap for `ConductorLaunch.Prompt`. +The direct message API limits one message to `MaxRenderedPromptBytes`, but the +orchestrator adds workflow, plan, repository, configuration, and +`` context. Other launch paths can also provide a prompt. A +future change must define an explicit token or byte budget for the complete +composed prompt and the behavior when that budget is exceeded, such as +compaction or a user-visible rejection. The budget must come from provider +capability metadata, not from a provider-name branch in the routing conductor. +Until that contract exists, provider context-window errors remain the +downstream safety boundary. An arbitrary cap here could silently remove +instructions or identifiers and would reintroduce the defect this design fixes. + The key match is a substring match, not exact-name, so it also matches a key merely containing a keyword without naming a credential (`max_tokens`, `tokenizer`); an exact-name allowlist would drop the qualified env-var keys diff --git a/docs/specs/platform/system-design/provider-error-recovery.md b/docs/specs/platform/system-design/provider-error-recovery.md index 11b69003ce..78472408e7 100644 --- a/docs/specs/platform/system-design/provider-error-recovery.md +++ b/docs/specs/platform/system-design/provider-error-recovery.md @@ -448,7 +448,7 @@ stored in policy or route state. Continuation package sanitization tiers are defined in [Part 4](provider-error-recovery-04.md#continuation-package-sanitization-tiers), -relocated there verbatim when this file reached its size limit. +relocated there at the size limit, and extended since. ## API surface