Keep transient routing out of completion receipts / 避免内部能力路由污染完成回执 (#8317)

* fix(agent): keep transient routing out of completion receipts

Problem:
Ordinary turns could surface an unverified completion receipt whose acceptance criterion exposed the internal capability-route block and invented a required-change check.

Root cause:
The agent kept the provider-composed turn as the completion contract input even though the controller already supplied authenticated raw user text separately.

Fix:
Use the raw user input for live and final completion contracts while preserving the composed provider prompt. Add regression coverage for mutation receipts and advisory turns containing capability routing with delivery-like wording.

Verification:
- go test ./internal/agent ./internal/completion ./internal/taskcontract
- go test ./...
- cd desktop && go test ./...
- git diff --check

* fix(agent): preserve trusted task text in completion contracts

Problem:
The first completion-receipt fix used raw user input unconditionally, which did not preserve Goal scope task text or pristine sub-agent classifier text. Its explanatory comment also exceeded repository lint budgets.

Root cause:
Completion contracts and delivery classification selected their task source independently even though both require the same host-trusted text.

Fix:
Store the already-selected trusted classification input as the per-turn contract input. Add regressions for Goal continuations and host-wrapped sub-agents, and remove the lint-expanding comment.

Verification:
- go run ./tools/repolint
- go test -race ./internal/agent -run TestCompletionContract -count=1
- go test ./internal/agent ./internal/completion ./internal/taskcontract
- go vet ./...
- go test ./...
- cd desktop && go test ./...
- git diff --check

* chore(ci): refresh PR metadata checks

Problem:
The documentation-impact field was added after the pull_request_target event was created, and rerunning that workflow reused the old event payload.

Root cause:
GitHub Actions reruns preserve the original pull request metadata snapshot.

Fix:
Create a new synchronize event so metadata guards evaluate the corrected PR body without rewriting published history.

Verification:
- PR body contains Documentation-impact
- local code and test gates passed at b0fb0fbcd
This commit is contained in:
SivanCola authored and GitHub committed 2026-08-11 14:03:53 +08:00
1 parent f1b5997b15
commit 175455df43
2 files changed
+93 -9

No files matched your search

+9 -9
View File
@@ -113,7 +113,7 @@ func (a *Agent) beginRunTurn(ctx context.Context, input string) (rawInput string
// A fresh user turn starts from zeroed per-turn host state; the new turn's
// values are computed below. Cross-turn state (checkpoint, scope, failure
// budgets) lives directly on Agent and is reconciled field by field.
a.perTurnState = perTurnState{turnInput: input}
a.perTurnState = perTurnState{}
a.resetStructuralRunGuards()
scope, scoped := DeliveryExecutionScopeFromContext(ctx)
preserveEvidence := a.preserveEvidenceOnce
@@ -178,17 +178,17 @@ func (a *Agent) beginRunTurn(ctx context.Context, input string) (rawInput string
// deadlocked read-only subagents. Without the override the raw input is
// classified verbatim: stripping user-controllable markup here would let
// input dressed up as host framing disarm the delivery gates.
classifierInput := a.classifierTaskText
a.turnInput = a.classifierTaskText
if scoped && strings.TrimSpace(scope.TaskText) != "" {
classifierInput = scope.TaskText
} else if strings.TrimSpace(classifierInput) == "" {
classifierInput = rawInput
a.turnInput = scope.TaskText
} else if strings.TrimSpace(a.turnInput) == "" {
a.turnInput = rawInput
}
intent := taskintent.Classify(classifierInput)
intent := taskintent.Classify(a.turnInput)
a.deliveryTaskExpected = intent.NeedsEvidence()
a.deliveryMutationExpected = intent == taskintent.Mutation && registryHasWriterTools(a.tools)
a.deliveryPersistentExpected = taskintent.NeedsPersistentAction(classifierInput)
a.recoveryTaskSummary = boundedRecoveryTaskSummary(classifierInput)
a.deliveryPersistentExpected = taskintent.NeedsPersistentAction(a.turnInput)
a.recoveryTaskSummary = boundedRecoveryTaskSummary(a.turnInput)
// A cancelled/error turn leaves a provider-excluded recovery record at the
// transcript tail. Fold its bounded facts into this new user turn exactly
// once; the user's raw text remains the classifier source above.
@@ -563,7 +563,7 @@ func (a *Agent) handleFinalResponse(ctx context.Context, state *runLoopState, te
if readiness.applies {
event.RecordReadinessAudit(a.sink, readiness.audit(evidence.ReadinessAllowed, a.readinessRecovered))
}
a.emitTurnShadows(state.input)
a.emitTurnShadows(a.turnInput)
if !a.closeSteerIntakeIfIdle() {
return true, nil
}
+84
View File
@@ -3,6 +3,7 @@ package agent
import (
"context"
"encoding/json"
"strings"
"testing"
"reasonix/internal/event"
@@ -69,6 +70,89 @@ func TestRunPersistsRawUserInputSeparatelyFromProviderContext(t *testing.T) {
if legacy.Content != composed {
t.Fatalf("previous-release reader sees %q, want provider-visible %q", legacy.Content, composed)
}
receipt := a.CompletionReceipt()
if receipt == nil {
t.Fatal("completion receipt is nil")
}
if len(receipt.Gaps) != 2 {
t.Fatalf("completion gaps = %+v, want the atomic requirement and mutation check", receipt.Gaps)
}
if got := receipt.Gaps[0].Detail; got != "r1: "+raw {
t.Fatalf("atomic criterion = %q, want raw user input %q", got, raw)
}
if strings.Contains(receipt.Gaps[0].Detail, "capability-route") {
t.Fatalf("completion receipt leaked transient provider context: %+v", receipt.Gaps)
}
}
func TestTransientCapabilityRouteCannotTurnConversationIntoDeliveryReceipt(t *testing.T) {
prov := &userInputCaptureProvider{}
a := New(prov, tool.NewRegistry(), NewSession("system"), Options{}, event.Discard)
const raw = "请解释这个项目目前的进度"
const composed = `<capability-route version="1">
Relevant capabilities for this turn:
- skill:minimax-docx prefer: the skill trigger matches the user request
Policy: prefer means use the skill for the required change
</capability-route>
` + raw
if err := a.Run(WithRawUserInput(context.Background(), raw), composed); err != nil {
t.Fatalf("Run: %v", err)
}
if a.CompletionReceipt() != nil {
t.Fatalf("an advisory turn received a delivery receipt from transient routing: %+v", a.CompletionReceipt())
}
if len(prov.request.Messages) < 2 ||
!strings.Contains(prov.request.Messages[1].Content, `<capability-route version="1">`) ||
!strings.Contains(prov.request.Messages[1].Content, raw) {
t.Fatalf("provider lost the capability route: %+v", prov.request.Messages)
}
if got := a.turnInput; got != raw {
t.Fatalf("contract input = %q, want authenticated raw input %q", got, raw)
}
c := a.LiveContract()
if c == nil || len(c.Requirements) != 0 || len(c.Checks) != 0 {
t.Fatalf("transient route created delivery requirements: %+v", c)
}
}
func TestCompletionContractUsesGoalScopeTaskText(t *testing.T) {
prov := &userInputCaptureProvider{}
a := New(prov, tool.NewRegistry(), NewSession("system"), Options{}, event.Discard)
ctx := WithRawUserInput(context.Background(), "Continue working.")
ctx = WithDeliveryExecutionScope(ctx, DeliveryExecutionScope{ID: "goal-1", TaskText: "fix the parser"})
if err := a.Run(ctx, "<goal-context>continue</goal-context>"); err != nil {
t.Fatalf("Run: %v", err)
}
assertAtomicCriterion(t, a, "fix the parser")
}
func TestCompletionContractUsesPristineSubagentTaskText(t *testing.T) {
prov := &userInputCaptureProvider{}
a := New(prov, tool.NewRegistry(), NewSession("system"), Options{
ClassifierTaskText: "fix the parser",
}, event.Discard)
const wrapped = "<workspace-context>private host framing</workspace-context>\n\nfix the parser"
if err := a.Run(context.Background(), wrapped); err != nil {
t.Fatalf("Run: %v", err)
}
assertAtomicCriterion(t, a, "fix the parser")
}
func assertAtomicCriterion(t *testing.T, a *Agent, want string) {
t.Helper()
receipt := a.CompletionReceipt()
if receipt == nil || len(receipt.Gaps) == 0 {
t.Fatalf("completion receipt = %+v, want atomic criterion %q", receipt, want)
}
if got := receipt.Gaps[0].Detail; got != "r1: "+want {
t.Fatalf("atomic criterion = %q, want %q", got, want)
}
}
func TestSubagentImageCandidatesAreCopiedAndIsolated(t *testing.T) {