refactor(agent): let receipts decide, and retire the word lists that guessed

A turn's obligations were being read off its wording. taskintent held ~1200
lines of CN/EN keyword tables, hand-rolled negation windows, and clause
splitting to answer "is this a mutation request?", and final readiness then
demanded a state change from any sentence that scored one. Its own doc.go
called the vocabulary a liability; boundary_test.go existed to stop it growing.

Readiness now reads the ledger: a turn that changed nothing owes nothing, and
one that did owes the verification, review, and sign-off it already owed. The
contract's intent label follows the same rule -- taskcontract.Kind is observed
from receipts after the fact instead of predicted from text -- and the atomic
contract shape is triggered by a mutation receipt rather than by a verb.

taskpolicy keeps the constraints a user stated outright ("don't modify", "only
run go test"): those restrict, and the user meant them. What goes is the risk
guessing, where "migrate" or "credential" bought a full plan and a forced
review from a topic word rather than from what the turn touches. Risk now
arrives only as host-computed signals.

Also removes machinery that decided nothing in production: the EBM and
reasoning-governor experiments (env-gated, off), the fork-bundle capture that
served them, and the shadow delegation-admission verdict. The per-round
contract replay went with them -- it rebuilt the whole contract from every
receipt on every tool round, quadratic in a long turn, for an audit channel
only the bench implements.

Net -3382 lines. Behavior change: a Delivery turn that claims work without
calling a tool is no longer refused. Catching that belongs to the model's own
claim (update_goal / complete_step), which internal/completion already checks
against the ledger, not to a keyword table.

Cache-impact: none - no tool schema, prompt, or memory text changes; the
per-turn <execution-policy> block rides the turn tail as before and its
route/risk values are derived from the same frozen preset.
Cache-guard: go test ./internal/boot/ (prefix_shape golden unchanged)
Documentation-impact: none - taskintent and the two experiments were never
user-facing; no docs/*.md describes the removed gates.
This commit is contained in:
esengine committed 2026-08-14 18:24:46 -07:00
1 parent 7469d77073
commit b071fef950
67 files changed
+311 -3693

No files matched your search

-25
View File
@@ -43,31 +43,6 @@ func (t *trajScan) recordRound(outcome string, gap gapInfo, b *toolBatch) {
}
}
// renderDelegationAdmission aggregates the shadow admission verdicts: how many
// expensive delegation calls a local-fix boundary would have refused, and the
// subagent time those refusals would have reclaimed.
func renderDelegationAdmission(results []result) string {
calls, denies := 0, 0
var deniedMs int64
for _, r := range results {
if r.Trajectory == nil {
continue
}
calls += r.Trajectory.DelegationCalls
denies += r.Trajectory.DelegationDenies
deniedMs += r.Trajectory.DeniedDelegationMs
}
if calls == 0 {
return ""
}
line := fmt.Sprintf("**Delegation admission** (shadow): **%d** gated calls · **would deny** %d (%s)",
calls, denies, pct(denies, calls))
if deniedMs > 0 {
line += fmt.Sprintf(" · **subagent time behind denied calls** %s", dur(deniedMs))
}
return line + "\n\n"
}
// renderCognition prices what the model's thinking bought: totals, the output
// rate (uniform rates indict token volume, not serving), the slow-round
// census, and delegation cost. Empty when no run carried usage-joined rounds.
-24
View File
@@ -102,27 +102,3 @@ func TestRenderCognitionPricesThinking(t *testing.T) {
t.Error("no runs must render nothing")
}
}
func TestSummarizeTrajectoryJoinsDeniedDelegationTime(t *testing.T) {
path := writeTrajectory(t, "admit.trajectory.jsonl", []string{
`{"seq":1,"ts":1000,"event":{"kind":"turn_started"}}`,
`{"seq":2,"ts":2000,"event":{"kind":"tool_dispatch","tool":{"id":"a","name":"research"}}}`,
`{"seq":3,"ts":9000,"event":{"kind":"tool_result","tool":{"id":"a","name":"research","durationMs":7000}}}`,
`{"seq":4,"ts":9100,"delegation_admission":{"tool":"research","verdict":"deny","reason":"local_fix_no_external_need"}}`,
`{"seq":5,"ts":10000,"event":{"kind":"turn_done"}}`,
})
s, err := summarizeTrajectory(path)
if err != nil {
t.Fatalf("summarizeTrajectory: %v", err)
}
if s.DelegationCalls != 1 || s.DelegationDenies != 1 || s.DeniedDelegationMs != 7000 {
t.Fatalf("admission join = calls %d denies %d denied_ms %d, want 1/1/7000",
s.DelegationCalls, s.DelegationDenies, s.DeniedDelegationMs)
}
got := renderDelegationAdmission([]result{{Trajectory: s}})
for _, want := range []string{"**1** gated calls", "**would deny** 1 (100%)", "denied calls** 7.0s"} {
if !strings.Contains(got, want) {
t.Errorf("render missing %q in:\n%s", want, got)
}
}
}
-208
View File
@@ -1,208 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"reasonix/internal/agent"
)
// forkRow is one continuation run from a frozen eligibility state: the pair
// analysis unit. Post-fork metrics need no alignment — the whole run IS the
// post-trigger tail, so its totals are Remaining-Cost-From-Eligibility.
type forkRow struct {
Task string `json:"task"`
Class string `json:"class"`
Arm string `json:"arm"`
Rep int `json:"rep"`
Passed bool `json:"passed"`
WallMs int64 `json:"wall_ms"`
Rounds int `json:"rounds"`
ReasoningTok int64 `json:"reasoning_tok"`
ExtraBlind int `json:"extra_blind"`
FirstCheckRnds int `json:"first_check_rounds"` // 0 = never
BlindAtFork int `json:"blind_at_fork"`
DebtAtFork int `json:"debt_at_fork"`
EligibleRound int `json:"eligible_round"`
}
// runForkMode replays every captured bundle under the requested arms. Arms
// alternate order per rep so provider drift over the session cannot
// systematically favor one arm.
func runForkMode(bundlesDir, suite, arms string, reps int, cfg suiteConfig, trajDir, outMD, outJSON string) error {
entries, err := os.ReadDir(bundlesDir)
if err != nil {
return err
}
tasks, err := loadTasks(suite)
if err != nil {
return err
}
byID := map[string]task{}
for _, t := range tasks {
byID[t.ID] = t
}
armList := strings.Split(arms, ",")
var rows []forkRow
for _, e := range entries {
if !e.IsDir() {
continue
}
bdir := filepath.Join(bundlesDir, e.Name())
bundle, err := agent.LoadForkBundle(filepath.Join(bdir, "bundle.json"))
if err != nil {
fmt.Fprintf(os.Stderr, "skip %s: %v\n", e.Name(), err)
continue
}
t, ok := byID[e.Name()]
if !ok {
fmt.Fprintf(os.Stderr, "skip %s: no such task in suite\n", e.Name())
continue
}
for rep := 1; rep <= reps; rep++ {
order := append([]string(nil), armList...)
if rep%2 == 0 {
sort.Sort(sort.Reverse(sort.StringSlice(order)))
}
for _, arm := range order {
row, err := runForkContinuation(cfg, t, bundle, bdir, arm, rep, trajDir)
if err != nil {
fmt.Fprintf(os.Stderr, "%s/%s rep %d: %v\n", e.Name(), arm, rep, err)
continue
}
rows = append(rows, row)
fmt.Fprintf(os.Stderr, "fork %s %s rep%d: pass=%v wall=%s rounds=%d check@%d\n",
row.Task, row.Arm, rep, row.Passed, dur(row.WallMs), row.Rounds, row.FirstCheckRnds)
}
}
}
report := renderForkReport(rows)
emit(report, outMD, "")
if outJSON != "" {
data, _ := json.MarshalIndent(rows, "", " ")
if err := os.WriteFile(outJSON, data, 0o644); err != nil {
return err
}
}
return nil
}
func runForkContinuation(cfg suiteConfig, t task, b *agent.ForkBundle, bdir, arm string, rep int, trajDir string) (forkRow, error) {
row := forkRow{Task: t.ID, Class: t.Class, Arm: arm, Rep: rep,
BlindAtFork: b.BlindAtFork, DebtAtFork: b.DebtAtFork, EligibleRound: b.EligibleRound}
work, err := os.MkdirTemp("", "fork-"+t.ID+"-")
if err != nil {
return row, err
}
defer os.RemoveAll(work)
if err := copyDir(filepath.Join(bdir, "workspace"), work); err != nil {
return row, err
}
trajPath := ""
if trajDir != "" {
trajPath = filepath.Join(trajDir, fmt.Sprintf("%s.%s.r%d.trajectory.jsonl", t.ID, arm, rep))
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(t.TimeoutSec)*time.Second)
defer cancel()
// Governor arms: low-effort cuts the whole continuation's thinking via
// the provider knob; act-first injects the shaping line instead. Both use
// the same frozen state as control.
runCfg, forkEnvArm := cfg, arm
switch arm {
case "low-effort":
runCfg.effort = "low"
forkEnvArm = "control"
case "act-first":
forkEnvArm = "actfirst"
}
args := buildRunTaskArgs(runCfg, filepath.Join(work, ".run-metrics.json"), trajPath, t.MaxSteps, b.Input)
cmd := exec.CommandContext(ctx, cfg.bin, args...)
cmd.Dir = work
cmd.Env = append(os.Environ(),
"REASONIX_EXPERIMENT_FORK_BUNDLE="+filepath.Join(bdir, "bundle.json"),
"REASONIX_EXPERIMENT_FORK_ARM="+forkEnvArm)
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
cmd.WaitDelay = 10 * time.Second
started := time.Now()
_ = cmd.Run()
row.WallMs = time.Since(started).Milliseconds()
row.Passed = grade(work, t.dir)
if trajPath != "" {
if scan, err := scanTrajectoryFile(trajPath); err == nil {
s := scan.finish()
row.Rounds = len(scan.outcomePoints)
row.ReasoningTok = s.ReasoningTokensTotal
for i, p := range scan.outcomePoints {
if p.discriminating > 0 {
row.FirstCheckRnds = i + 1
break
}
row.ExtraBlind += p.churn
}
}
}
return row, nil
}
// renderForkReport is deliberately per-pair first, per-class second, and never
// one blended score: the arms answer different questions per class.
func renderForkReport(rows []forkRow) string {
var b strings.Builder
b.WriteString("## Fork continuation report\n\n")
b.WriteString("| task | class | arm | rep | pass | wall | rounds | reasoning | extra blind | check@ |\n")
b.WriteString("|---|---|---|---|---|---|---|---|---|---|\n")
for _, r := range rows {
check := "never"
if r.FirstCheckRnds > 0 {
check = fmt.Sprint(r.FirstCheckRnds)
}
fmt.Fprintf(&b, "| %s | %s | %s | %d | %v | %s | %d | %s | %d | %s |\n",
r.Task, r.Class, r.Arm, r.Rep, r.Passed, dur(r.WallMs), r.Rounds,
comma(int(r.ReasoningTok)), r.ExtraBlind, check)
}
type agg struct {
n, pass, checks, extraBlind int
wall, reasoning int64
}
byClassArm := map[string]*agg{}
for _, r := range rows {
key := r.Class + "/" + r.Arm
a := byClassArm[key]
if a == nil {
a = &agg{}
byClassArm[key] = a
}
a.n++
if r.Passed {
a.pass++
}
if r.FirstCheckRnds > 0 {
a.checks++
}
a.extraBlind += r.ExtraBlind
a.wall += r.WallMs
a.reasoning += r.ReasoningTok
}
keys := make([]string, 0, len(byClassArm))
for k := range byClassArm {
keys = append(keys, k)
}
sort.Strings(keys)
b.WriteString("\n| class/arm | n | pass | checked | avg wall | avg reasoning | avg extra blind |\n")
b.WriteString("|---|---|---|---|---|---|---|\n")
for _, k := range keys {
a := byClassArm[k]
fmt.Fprintf(&b, "| %s | %d | %s | %s | %s | %s | %.1f |\n",
k, a.n, pct(a.pass, a.n), pct(a.checks, a.n),
dur(a.wall/int64(a.n)), comma(int(a.reasoning/int64(a.n))), float64(a.extraBlind)/float64(a.n))
}
return b.String()
}
+4 -16
View File
@@ -237,7 +237,7 @@ func main() {
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -profile delivery\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
}
mode := flag.String("mode", "suite", "suite | diff | swebench | compare | traj | serve | fork")
mode := flag.String("mode", "suite", "suite | diff | swebench | compare | traj | serve")
addr := flag.String("addr", "127.0.0.1:7480", "serve mode: live dashboard listen address")
subset := flag.String("subset", "benchmarks/swebench/subset.json", "swebench mode: instance subset file")
namespace := flag.String("namespace", "swebench", "swebench mode: registry namespace holding the evaluation images")
@@ -255,11 +255,7 @@ func main() {
effort := flag.String("effort", "", "reasoning effort override passed to the agent (model-specific levels, e.g. disabled|low|high|max); empty = model default")
checkpoints := flag.Bool("checkpoints", false, "suite mode: snapshot the workdir on every change and grade each snapshot offline after the run, yielding first_correct_ms (TTFCS) and post_solve_waste_ms")
pressure := registerPressureFlags()
policyFlag := flag.String("policy", "", "suite mode: experiment arm — empty (baseline) | ebm (evidence-before-more-mutation nudge) | governor (exploration-phase reasoning governor) | memory-off (hide the memory store: MemoryBench counterfactual arm)")
forkCapture := flag.String("fork-capture", "", "suite mode: capture a fork bundle per task at first EBM eligibility into <dir>/<task-id>")
bundles := flag.String("bundles", "", "fork mode: directory of captured bundles (<task-id>/bundle.json)")
forkArms := flag.String("arm", "control,treatment", "fork mode: comma-separated continuation arms (control | treatment)")
forkReps := flag.Int("reps", 1, "fork mode: continuation repetitions per bundle per arm")
policyFlag := flag.String("policy", "", "suite mode: experiment arm — empty (baseline) | memory-off (hide the memory store: MemoryBench counterfactual arm)")
bin := flag.String("bin", "reasonix", "path to the reasonix binary")
model := flag.String("model", "", "provider/model name (default: config default)")
profileFlag := flag.String("profile", benchmarkProfileBaseline, "prompt profile: baseline | delivery")
@@ -318,14 +314,6 @@ func main() {
os.Exit(1)
}
return
case "fork":
cfg := suiteConfig{bin: *bin, model: *model, profile: profile, arm: arm,
cacheArm: cache, effort: *effort, policy: *policyFlag}
if err := runForkMode(*bundles, *suite, *forkArms, *forkReps, cfg, *trajDir, *outMD, *outJSON); err != nil {
fmt.Fprintln(os.Stderr, "fork mode:", err)
os.Exit(1)
}
return
}
if *mode == "diff" {
@@ -342,7 +330,7 @@ func main() {
bin: *bin, model: *model, profile: profile, arm: arm, budget: *budget,
trajDir: *trajDir, forcePlanner: *forcePlanner, attempts: *attempts, anchor: anchor,
cacheArm: cache, effort: *effort, checkpoints: *checkpoints, policy: *policyFlag,
forkCapture: *forkCapture, meterConfig: meterSource, meterFaults: faults, segments: segments, steers: steers,
meterConfig: meterSource, meterFaults: faults, segments: segments, steers: steers,
}, *suite, *taskFilter, *outMD, *outJSON)
}
@@ -475,7 +463,7 @@ type suiteConfig struct {
bin, model, profile, cacheArm, effort string
arm ablation.Set
anchor string
policy, forkCapture string
policy string
trajDir string
forcePlanner, checkpoints bool
attempts, budget int
+3 -11
View File
@@ -58,20 +58,12 @@ func seedTaskMemory(taskDir, work string) ([]string, error) {
}
// taskExperimentEnv assembles one run's experiment environment: the policy
// arm, fork capture, and the seeded memory state root. The note reports a
// seeding failure without aborting the run.
// arm and the seeded memory state root. The note reports a seeding failure
// without aborting the run.
func taskExperimentEnv(cfg suiteConfig, t task, work string) (env []string, note string) {
switch cfg.policy {
case "ebm":
env = append(env, "REASONIX_EXPERIMENT_EBM=1")
case "governor":
env = append(env, "REASONIX_EXPERIMENT_GOVERNOR=1")
case "memory-off":
if cfg.policy == "memory-off" {
env = append(env, "REASONIX_EXPERIMENT_NO_MEMORY=1")
}
if cfg.forkCapture != "" {
env = append(env, "REASONIX_EXPERIMENT_FORK_CAPTURE_DIR="+filepath.Join(cfg.forkCapture, t.ID))
}
seedEnv, err := seedTaskMemory(t.dir, work)
if err != nil {
return env, "memory seed: " + err.Error()
+9 -137
View File
@@ -23,31 +23,10 @@ type outcomeSummary struct {
DebtAgeMax int `json:"debt_age_max,omitempty"`
Backfilled bool `json:"backfilled,omitempty"`
// EBM chain: when the Evidence-Before-More-Mutation trigger held, when its
// nudge fired, and what followed — compliance is the mechanism-health
// readout that separates "nudge ignored" from "early evidence useless".
DebtArea int `json:"debt_area,omitempty"`
BlindPeak int `json:"blind_peak,omitempty"`
EBMEligibleRound int `json:"ebm_eligible_round,omitempty"`
EBMFiredRound int `json:"ebm_fired_round,omitempty"`
EBMBlindAtFire int `json:"ebm_blind_at_fire,omitempty"`
EBMDebtAgeAtFire int `json:"ebm_debt_age_at_fire,omitempty"`
EBMRoundsToCheck int `json:"ebm_rounds_to_check,omitempty"`
EBMMsToCheck int64 `json:"ebm_ms_to_check,omitempty"`
EBMReasoningToCheck int64 `json:"ebm_reasoning_to_check,omitempty"`
EBMCheckWithin1 bool `json:"ebm_check_within_1,omitempty"`
EBMCheckWithin2 bool `json:"ebm_check_within_2,omitempty"`
// EBMExtraBlind counts mutations between the nudge and the first
// discriminating check: 0 = strong compliance, 1 = finishing the minimum
// coherent patch the copy permits, >=2 = real non-compliance.
EBMExtraBlind int `json:"ebm_extra_blind_mutations"`
EBMPostSequence string `json:"ebm_post_sequence,omitempty"`
// Governor chain: rounds the exploration trigger held, rounds the depth
// override actually rode requests, and where the first engagement sat.
GovernorEligibleRounds int `json:"governor_eligible_rounds,omitempty"`
GovernorEngagedRounds int `json:"governor_engaged_rounds,omitempty"`
GovernorFirstRound int `json:"governor_first_round,omitempty"`
// DebtArea is the run's total unverified-mutation exposure (debt age summed
// over rounds); BlindPeak the worst run of mutations with no check between.
DebtArea int `json:"debt_area,omitempty"`
BlindPeak int `json:"blind_peak,omitempty"`
}
// outcomePoint is one recorded shadow sample plus its observation time.
@@ -55,8 +34,6 @@ type outcomePoint struct {
ts int64
exploration, verification, objective, regression, churn int
legacyGain, discriminating, debtAge, blindMutations int
ebmEligible, ebmFired bool
governorEligible, governorEngaged bool
}
// verifyPoint is one backfilled verification-transition observation.
@@ -94,7 +71,7 @@ func (t *trajScan) observeVerification(key string, passed bool, ts int64) {
func (t *trajScan) summarizeOutcome() *outcomeSummary {
if len(t.outcomePoints) > 0 {
o := summarizeOutcomePoints(t.outcomePoints, t.firstTS, t.lastTS)
t.attachEBMChain(o)
t.attachDebtExposure(o)
return o
}
if len(t.verifyPoints) > 0 {
@@ -161,88 +138,13 @@ func summarizeOutcomePoints(points []outcomePoint, firstTS, lastTS int64) *outco
return o
}
// attachEBMChain condenses the per-round EBM shadow into the run's chain
// facts; rounds-to-check joins the cognition digests for the tokens spent
// between fire and first discriminating observation.
func (t *trajScan) attachEBMChain(o *outcomeSummary) {
pts := t.outcomePoints
fire := -1
for i, p := range pts {
// attachDebtExposure sums the run's unverified-mutation exposure: how long
// debt stayed open and how many mutations ever stacked up without a check.
func (t *trajScan) attachDebtExposure(o *outcomeSummary) {
for _, p := range t.outcomePoints {
o.DebtArea += p.debtAge
o.BlindPeak = max(o.BlindPeak, p.blindMutations)
if o.EBMEligibleRound == 0 && p.ebmEligible {
o.EBMEligibleRound = i + 1
}
if p.governorEligible {
o.GovernorEligibleRounds++
}
if p.governorEngaged {
o.GovernorEngagedRounds++
if o.GovernorFirstRound == 0 {
o.GovernorFirstRound = i + 1
}
}
if fire < 0 && p.ebmFired {
fire = i
}
}
if fire < 0 {
return
}
o.EBMFiredRound = fire + 1
o.EBMBlindAtFire = pts[fire].blindMutations
o.EBMDebtAgeAtFire = pts[fire].debtAge
for j := fire + 1; j < len(pts); j++ {
o.EBMPostSequence += postEBMCategory(pts[j])
if pts[j].discriminating == 0 {
o.EBMExtraBlind += pts[j].churn
continue
}
o.EBMRoundsToCheck = j - fire
o.EBMMsToCheck = pts[j].ts - pts[fire].ts
o.EBMCheckWithin1 = j-fire <= 1
o.EBMCheckWithin2 = j-fire <= 2
for k := fire + 1; k <= j && k < len(t.s.Rounds); k++ {
o.EBMReasoningToCheck += t.s.Rounds[k].ReasoningTokens
}
return
}
}
// postEBMCategory letters the rounds after a nudge: V discriminating check,
// M mutation, R new information, "." quiet.
func postEBMCategory(p outcomePoint) string {
switch {
case p.discriminating > 0:
return "V"
case p.churn > 0:
return "M"
case p.exploration > 0:
return "R"
default:
return "."
}
}
// governorShadowLine aggregates the reasoning-governor shadow across runs;
// empty when no round was eligible.
func governorShadowLine(results []result) string {
runs, elig, engaged := 0, 0, 0
for _, r := range results {
if r.Trajectory == nil || r.Trajectory.Outcome == nil {
continue
}
o := r.Trajectory.Outcome
if o.GovernorEligibleRounds > 0 {
runs++
}
elig += o.GovernorEligibleRounds
engaged += o.GovernorEngagedRounds
}
if elig == 0 {
return ""
}
return fmt.Sprintf(" · **governor** eligible %d rounds in %d runs · engaged %d rounds", elig, runs, engaged)
}
func summarizeVerifyBackfill(points []verifyPoint, lastTS int64) *outcomeSummary {
@@ -324,36 +226,6 @@ func renderOutcomeProgress(results []result) string {
if debtMax > 0 {
line += fmt.Sprintf(" · **verification debt max** %d rounds", debtMax)
}
elig, fired, comply := 0, 0, 0
var toCheck []int64
for _, r := range results {
if r.Trajectory == nil || r.Trajectory.Outcome == nil {
continue
}
o := r.Trajectory.Outcome
if o.EBMEligibleRound > 0 {
elig++
}
if o.EBMFiredRound > 0 {
fired++
// Compliance judges behavior, not speed: at most one mutation
// between nudge and check — the coherent-patch allowance.
if o.EBMRoundsToCheck > 0 && o.EBMExtraBlind <= 1 {
comply++
}
if o.EBMRoundsToCheck > 0 {
toCheck = append(toCheck, int64(o.EBMRoundsToCheck))
}
}
}
if elig > 0 {
line += fmt.Sprintf(" · **EBM** eligible %d · fired %d", elig, fired)
if fired > 0 {
line += fmt.Sprintf(" (compliance ≤1 extra mutation %s, median rounds-to-check %d)",
pct(comply, fired), median(toCheck))
}
}
line += governorShadowLine(results)
if progress > 0 {
line += fmt.Sprintf(" · **false progress** %d/%d (%s)", falseProgress, progress, pct(falseProgress, progress))
}
+5 -22
View File
@@ -126,11 +126,11 @@ func TestSummarizeOutcomeTracksDebtAndTTFDC(t *testing.T) {
}
}
func TestSummarizeOutcomeDerivesEBMChain(t *testing.T) {
path := writeTrajectory(t, "ebm.trajectory.jsonl", []string{
func TestSummarizeOutcomeSumsDebtExposure(t *testing.T) {
path := writeTrajectory(t, "debt-area.trajectory.jsonl", []string{
`{"seq":1,"ts":1000,"event":{"kind":"turn_started"}}`,
`{"seq":2,"ts":2000,"outcome_progress":{"round":1,"churn":2,"debt_age":1,"blind_mutations":2}}`,
`{"seq":3,"ts":3000,"outcome_progress":{"round":2,"churn":1,"debt_age":2,"blind_mutations":3,"ebm_eligible":true,"ebm_fired":true}}`,
`{"seq":3,"ts":3000,"outcome_progress":{"round":2,"churn":1,"debt_age":2,"blind_mutations":3}}`,
`{"seq":4,"ts":4000,"outcome_progress":{"round":3,"exploration":1,"debt_age":3,"blind_mutations":3}}`,
`{"seq":5,"ts":9000,"outcome_progress":{"round":4,"discriminating":1,"verification":1}}`,
`{"seq":6,"ts":10000,"event":{"kind":"turn_done"}}`,
@@ -140,25 +140,8 @@ func TestSummarizeOutcomeDerivesEBMChain(t *testing.T) {
t.Fatalf("summarizeTrajectory: %v", err)
}
o := s.Outcome
if o == nil || o.EBMFiredRound != 2 || o.EBMEligibleRound != 2 {
t.Fatalf("outcome = %+v, want EBM fired/eligible at round 2", o)
}
if o.EBMBlindAtFire != 3 || o.EBMDebtAgeAtFire != 2 {
t.Errorf("at-fire = blind %d debt %d, want 3/2", o.EBMBlindAtFire, o.EBMDebtAgeAtFire)
}
if o.EBMRoundsToCheck != 2 || o.EBMMsToCheck != 6000 || o.EBMCheckWithin1 || !o.EBMCheckWithin2 {
t.Errorf("chain = rounds %d ms %d w1 %v w2 %v, want 2/6000/false/true",
o.EBMRoundsToCheck, o.EBMMsToCheck, o.EBMCheckWithin1, o.EBMCheckWithin2)
}
if o.DebtArea != 6 || o.BlindPeak != 3 {
t.Errorf("debt area %d blind peak %d, want 6/3", o.DebtArea, o.BlindPeak)
}
if o.EBMPostSequence != "RV" || o.EBMExtraBlind != 0 {
t.Errorf("post sequence %q extra %d, want RV/0", o.EBMPostSequence, o.EBMExtraBlind)
}
got := renderOutcomeProgress([]result{{Trajectory: s}})
if !strings.Contains(got, "**EBM** eligible 1 · fired 1 (compliance ≤1 extra mutation 100%, median rounds-to-check 2)") {
t.Errorf("render missing EBM segment:\n%s", got)
if o == nil || o.DebtArea != 6 || o.BlindPeak != 3 {
t.Fatalf("outcome = %+v, want debt area 6 and blind peak 3", o)
}
}
-1
View File
@@ -205,7 +205,6 @@ func renderBody(results []result) string {
b.WriteString(renderCognition(results))
b.WriteString(renderAnchor(results))
b.WriteString(renderDelegation(results))
b.WriteString(renderDelegationAdmission(results))
b.WriteString(renderMechanismLedger(results))
if s.unaccounted > 0 {
fmt.Fprintf(&b, "> **Accounting incomplete for %d of %d instances** (%d of them solved): the agent was killed before it wrote any metrics, so their cost and tokens are unknown. Totals above cover the %d accounted instances only, and per-solved figures divide by the %d accounted solves — the true totals are higher.\n\n",
-1
View File
@@ -91,7 +91,6 @@ type trajScan struct {
gapReason, gapCompl, gapPrompt int64
denyDelegations map[string]bool
delegationToolMs map[string]int64
}
+10 -40
View File
@@ -125,12 +125,6 @@ type trajectorySummary struct {
SlowRoundGapMs int64 `json:"slow_round_gap_ms,omitempty"`
SlowRoundReasoningTokens int64 `json:"slow_round_reasoning_tokens,omitempty"`
Rounds []roundDigest `json:"rounds,omitempty"`
// Delegation admission shadow: verdicts recorded by the runtime, and the
// subagent time spent by tools the shadow would have denied.
DelegationCalls int `json:"delegation_calls,omitempty"`
DelegationDenies int `json:"delegation_denies,omitempty"`
DeniedDelegationMs int64 `json:"denied_delegation_ms,omitempty"`
}
// toolWall is the best available tool wall-clock: interval union when the
@@ -159,26 +153,16 @@ type trajectoryRecord struct {
ClaimsUnbacked int `json:"claims_unbacked"`
} `json:"completion_report"`
OutcomeProgress *struct {
Exploration int `json:"exploration"`
Verification int `json:"verification"`
Objective int `json:"objective"`
Regression int `json:"regression"`
Churn int `json:"churn"`
LegacyGain int `json:"legacy_gain"`
Discriminating int `json:"discriminating"`
DebtAge int `json:"debt_age"`
BlindMutations int `json:"blind_mutations"`
EBMEligible bool `json:"ebm_eligible"`
EBMFired bool `json:"ebm_fired"`
LocalExecSeen bool `json:"local_exec_seen"`
GovernorEligible bool `json:"governor_eligible"`
GovernorEngaged bool `json:"governor_engaged"`
Exploration int `json:"exploration"`
Verification int `json:"verification"`
Objective int `json:"objective"`
Regression int `json:"regression"`
Churn int `json:"churn"`
LegacyGain int `json:"legacy_gain"`
Discriminating int `json:"discriminating"`
DebtAge int `json:"debt_age"`
BlindMutations int `json:"blind_mutations"`
} `json:"outcome_progress"`
DelegationAdmission *struct {
Tool string `json:"tool"`
Verdict string `json:"verdict"`
Reason string `json:"reason"`
} `json:"delegation_admission"`
Event *struct {
Kind string `json:"kind"`
Code string `json:"code"`
@@ -336,7 +320,6 @@ func scanTrajectoryFile(path string) (*trajScan, error) {
attemptBegin: map[string]int64{},
lastAttempt: -1,
seen: map[string]bool{},
denyDelegations: map[string]bool{},
delegationToolMs: map[string]int64{},
}
sc := bufio.NewScanner(f)
@@ -383,17 +366,9 @@ func (t *trajScan) record(rec trajectoryRecord) {
ts: rec.TS, exploration: op.Exploration, verification: op.Verification,
objective: op.Objective, regression: op.Regression, churn: op.Churn,
legacyGain: op.LegacyGain, discriminating: op.Discriminating, debtAge: op.DebtAge,
blindMutations: op.BlindMutations, ebmEligible: op.EBMEligible, ebmFired: op.EBMFired,
governorEligible: op.GovernorEligible, governorEngaged: op.GovernorEngaged,
blindMutations: op.BlindMutations,
})
}
if da := rec.DelegationAdmission; da != nil {
t.s.DelegationCalls++
if da.Verdict == "deny" {
t.s.DelegationDenies++
t.denyDelegations[da.Tool] = true
}
}
if rec.Event == nil {
return
}
@@ -699,11 +674,6 @@ func (t *trajScan) finish() *trajectorySummary {
s.ModelMs = 0
}
s.Outcome = t.summarizeOutcome()
// The admission verdict lands after the tool's result in the stream, so
// denied time joins by tool name once the whole file is folded.
for name := range t.denyDelegations {
s.DeniedDelegationMs += t.delegationToolMs[name]
}
t.decompose()
return s
}
+1 -1
View File
@@ -37,7 +37,7 @@ func runTrajMode(dir string) (string, error) {
}
fmt.Fprintf(&b, "### `%s`\n\n```json\n%s\n```\n\n", id, enc)
}
return "## Trajectory digest\n\n" + renderTimeAttribution(results) + renderToolSurface(results) + renderOutcomeProgress(results) + renderCognition(results) + renderDelegationAdmission(results) + renderMechanismLedger(results) + b.String(), nil
return "## Trajectory digest\n\n" + renderTimeAttribution(results) + renderToolSurface(results) + renderOutcomeProgress(results) + renderCognition(results) + renderMechanismLedger(results) + b.String(), nil
}
func emitTrajMode(dir, outMD string) {
+3 -28
View File
@@ -1129,8 +1129,6 @@ func New(prov provider.Provider, tools *tool.Registry, session *Session, opts Op
a.SetAgentPreset(preset)
a.SetResponseLanguage(opts.ResponseLanguage)
a.SetReasoningLanguage(opts.ReasoningLanguage)
a.maybeArmForkFromEnv()
a.maybeWrapForkCaptureProvider()
return a
}
@@ -1276,9 +1274,6 @@ func (a *Agent) Run(ctx context.Context, input string) (runErr error) {
}
_, state := a.beginRunTurn(ctx, input)
if a.pending.forkRestore != nil {
a.pending.forkRestore(state)
}
state.runMaxSteps = runMaxSteps
state.runMaxStepsKey = runMaxStepsKey
state.runLimitHostOwned = runLimitHostOwned
@@ -1364,8 +1359,8 @@ func (a *Agent) updateDeliveryCheckpoint(runErr error) {
}
cp.CriteriaEstablished = cp.CriteriaEstablished || a.turn.deliveryCriteriaEstablished || a.task.ledger.HasSuccessfulTodoWrite()
cp.WorkObserved = cp.WorkObserved || a.task.ledger.HasSuccessfulWorkReceipt()
persistentOnlyReady := a.turn.deliveryPersistentExpected && !a.turn.deliveryMutationExpected &&
a.task.ledger.HasSuccessfulToolReceipt("remember") && !a.task.ledger.HasSuccessfulMutationOtherThan("remember")
persistentOnlyReady := a.task.ledger.HasSuccessfulToolReceipt("remember") &&
!a.task.ledger.HasSuccessfulMutationOtherThan("remember")
if _, ok := a.task.ledger.LatestSuccessfulMutationIndex(); ok && !persistentOnlyReady {
cp.MutationObserved = true
cp.PendingMutation = true
@@ -1426,26 +1421,6 @@ func (a *Agent) canonicalTodoProgress() (int, bool) {
return completed, incomplete
}
// registryHasWriterTools reports whether any registered tool can mutate state.
// A strictly read-only registry (read_only_task / read_only_skill subagents)
// can never satisfy a "state change required" delivery expectation, so that
// expectation must not be armed for it.
func registryHasWriterTools(reg *tool.Registry) bool {
if reg == nil {
return false
}
for _, name := range reg.Names() {
if t, ok := reg.Get(name); ok && !t.ReadOnly() {
return true
}
}
return false
}
// advanceCanonicalTodo flips the canonical todo matching a signed-off step to
// completed (promoting the next pending item to in_progress) and emits a
// synthetic todo_write so the task panel reflects it without the model
// re-sending the whole list. No-op when nothing matches or it is already done.
func (a *Agent) advanceCanonicalTodo(step string) {
a.sess.todoMu.Lock()
if len(a.sess.todoState) == 0 {
@@ -1949,7 +1924,7 @@ func (a *Agent) streamWithFrozen(ctx context.Context, turn int, sink event.Sink,
responsesItems = append(responsesItems, append(json.RawMessage(nil), chunk.ResponsesItem...))
}
case provider.ChunkUsage:
usage, a.turn.lastReasoning = chunk.Usage, chunk.Usage.ReasoningTokens
usage = chunk.Usage
a.storeLatestRequestUsage(chunk.Usage)
a.sess.cacheHit.Add(int64(chunk.Usage.CacheHitTokens))
a.sess.cacheMiss.Add(int64(chunk.Usage.CacheMissTokens))
+10 -3
View File
@@ -7,6 +7,13 @@ import (
"reasonix/internal/event"
)
// noticeSink collects what the arbiter delivered to the frontend.
type noticeSink struct {
notices []event.Event
}
func (s *noticeSink) Emit(e event.Event) { s.notices = append(s.notices, e) }
func arbiterRound() ([]string, []toolOutcome) {
return []string{"tool output"}, []toolOutcome{{output: "tool output"}}
}
@@ -14,7 +21,7 @@ func arbiterRound() ([]string, []toolOutcome) {
// Every signal used to write results[0] from outcomes[0].output, so a round
// that fired two of them delivered only the last one's guidance.
func TestApplyInterventionsKeepsEveryFiredGuidance(t *testing.T) {
sink := &ebmSink{}
sink := &noticeSink{}
a := &Agent{svc: agentServices{sink: sink}}
results, outcomes := arbiterRound()
@@ -39,7 +46,7 @@ func TestApplyInterventionsKeepsEveryFiredGuidance(t *testing.T) {
}
func TestApplyInterventionsLeavesQuietRoundsUntouched(t *testing.T) {
sink := &ebmSink{}
sink := &noticeSink{}
a := &Agent{svc: agentServices{sink: sink}}
results, outcomes := arbiterRound()
@@ -53,7 +60,7 @@ func TestApplyInterventionsLeavesQuietRoundsUntouched(t *testing.T) {
// A land must survive being raised beside a lower tier in the same round.
func TestApplyInterventionsTakesTheStrongestVerdict(t *testing.T) {
a := &Agent{svc: agentServices{sink: &ebmSink{}}}
a := &Agent{svc: agentServices{sink: &noticeSink{}}}
results, outcomes := arbiterRound()
got := a.applyInterventions(results, outcomes,
+15 -30
View File
@@ -10,7 +10,6 @@ import (
"reasonix/internal/evidence"
"reasonix/internal/plancontract"
"reasonix/internal/taskcontract"
"reasonix/internal/taskintent"
)
// buildShadowContract replays a finished turn's receipts into a task
@@ -23,8 +22,10 @@ func buildShadowContract(input string, receipts []evidence.Receipt, plan *planco
switch {
case plan != nil:
c = taskcontract.FromPlan(input, planFacts(*plan))
case taskintent.Classify(input) == taskintent.Mutation,
taskintent.Classify(input) == taskintent.PersistentAction:
case receiptsCarryMutation(receipts):
// No plan stated the terms, but the turn changed something, so the ask
// itself is the requirement and the change is what answers for it. The
// trigger is the receipt: a turn that changed nothing owes no such claim.
c = taskcontract.Atomic(input)
default:
c = taskcontract.New(input)
@@ -54,6 +55,16 @@ func buildShadowContract(input string, receipts []evidence.Receipt, plan *planco
return c
}
// receiptsCarryMutation reports whether the turn actually changed something.
func receiptsCarryMutation(receipts []evidence.Receipt) bool {
for _, r := range receipts {
if r.Success && (r.Mutation || r.Write) {
return true
}
}
return false
}
// resolveCitedCriteria satisfies the criteria a successful complete_step named.
// The tool verified each proof against the ledger before succeeding, so what the
// citation adds is the binding: "the command ran" and "the criterion holds" are
@@ -115,7 +126,7 @@ func contractShadowAudit(c *taskcontract.Contract) event.ContractShadowAudit {
}
}
return event.ContractShadowAudit{
Intent: intentName(c.Intent),
Intent: c.Kind.String(),
Requirements: len(c.Requirements),
RequirementsSatisfied: reqDone,
Checks: len(c.Checks),
@@ -127,21 +138,6 @@ func contractShadowAudit(c *taskcontract.Contract) event.ContractShadowAudit {
}
}
func intentName(i taskintent.Intent) string {
switch i {
case taskintent.Advisory:
return "advisory"
case taskintent.ObservableRead:
return "observable_read"
case taskintent.Mutation:
return "mutation"
case taskintent.PersistentAction:
return "persistent_action"
default:
return "conversation"
}
}
// LiveContract is the contract as it stands right now: the same pure replay the
// turn ends with, run against the receipts recorded so far. Rebuilding beats
// keeping incremental state because one code path serves the per-round view and
@@ -153,17 +149,6 @@ func (a *Agent) LiveContract() *taskcontract.Contract {
return buildShadowContract(a.turn.turnInput, a.task.ledger.Receipts(), a.planContractSnapshot())
}
// observeContractRound records the contract after one tool round, so a
// trajectory carries how the evidence graph filled in rather than only where it
// landed. It observes; it decides nothing.
func (a *Agent) observeContractRound() {
c := a.LiveContract()
if c == nil || (len(c.Requirements) == 0 && len(c.Checks) == 0) {
return
}
event.RecordContractShadow(a.svc.sink, contractShadowAudit(c))
}
// emitTurnShadows records the end-of-turn shadow observations: the contract's
// state, and the completion report derived from it. Both observe; neither
// decides.
-62
View File
@@ -1,62 +0,0 @@
package agent
import (
"strings"
"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/taskintent"
)
// admissionGatedDelegations are the delegation tools expensive enough to need
// an admission reason on local-fix turns. task/fleet/explore joined research
// after a paired measurement: forcing delegation on identical work cost 2-4x
// the tokens and wall clock with no change in outcome. Shadow-only — a deny is
// recorded, never enforced; two tasks do not justify blocking a user's call.
var admissionGatedDelegations = map[string]bool{
"research": true,
"explore": true,
"task": true,
"fleet": true,
}
// researchRequestMarkers is the V1 shadow heuristic for "the user explicitly
// asked for external research"; deliberately narrow — a miss records a deny
// verdict, it never blocks the call.
var researchRequestMarkers = []string{"research", "调研", "查资料", "查文档", "search the web", "look up online"}
// delegationAdmission is the shadow verdict for one gated delegation call:
// local mutation-shaped turns get no expensive external research unless the
// user asked for it or the call cites an external source. Pure function.
func delegationAdmission(input, args string) (verdict, reason string, intent taskintent.Intent) {
intent = taskintent.Classify(input)
if intent != taskintent.Mutation && intent != taskintent.PersistentAction {
return "allow", "non_local_intent", intent
}
lower := strings.ToLower(input)
for _, marker := range researchRequestMarkers {
if strings.Contains(lower, marker) {
return "allow", "user_requested", intent
}
}
if strings.Contains(args, "http://") || strings.Contains(args, "https://") {
return "allow", "external_source_cited", intent
}
return "deny", "local_fix_no_external_need", intent
}
// observeDelegationAdmission records a shadow admission verdict for every
// gated delegation call in the batch. Observed, never enforced. The turn text
// is the bounded classifier source the delivery gates already store, so the
// shadow and the gates judge the same words.
func (a *Agent) observeDelegationAdmission(calls []provider.ToolCall) {
for _, call := range calls {
if !admissionGatedDelegations[call.Name] {
continue
}
verdict, reason, intent := delegationAdmission(a.turn.recoveryTaskSummary, call.Arguments)
event.RecordDelegationAdmission(a.svc.sink, event.DelegationAdmissionAudit{
Tool: call.Name, Verdict: verdict, Reason: reason, Intent: intentName(intent),
})
}
}
@@ -1,63 +0,0 @@
package agent
import (
"testing"
"reasonix/internal/event"
"reasonix/internal/provider"
)
func TestDelegationAdmissionVerdicts(t *testing.T) {
cases := []struct {
name, input, args, verdict, reason string
}{
{"local fix, plain query", "fix the config serializer bug in parser.go",
`{"prompt":"how does the serializer format keys"}`, "deny", "local_fix_no_external_need"},
{"user asked for research", "research the best TOML library and fix the loader",
`{"prompt":"compare toml libraries"}`, "allow", "user_requested"},
{"external source cited", "fix the retry logic to match the upstream spec",
`{"prompt":"read https://example.com/spec and summarize backoff rules"}`, "allow", "external_source_cited"},
{"advisory turn", "how does our retry budget compare to industry practice?",
`{"prompt":"survey retry budget conventions"}`, "allow", "non_local_intent"},
}
for _, c := range cases {
verdict, reason, _ := delegationAdmission(c.input, c.args)
if verdict != c.verdict || reason != c.reason {
t.Errorf("%s: got %s/%s, want %s/%s", c.name, verdict, reason, c.verdict, c.reason)
}
}
}
type admissionSink struct {
audits []event.DelegationAdmissionAudit
}
func (s *admissionSink) Emit(event.Event) {}
func (s *admissionSink) RecordDelegationAdmission(a event.DelegationAdmissionAudit) {
s.audits = append(s.audits, a)
}
func TestObserveDelegationAdmissionRecordsOnlyGatedTools(t *testing.T) {
sink := &admissionSink{}
a := &Agent{svc: agentServices{sink: sink}}
a.turn.recoveryTaskSummary = "fix the failing date parser"
a.observeDelegationAdmission([]provider.ToolCall{
{Name: "read_file", Arguments: `{"path":"a.go"}`},
{Name: "research", Arguments: `{"prompt":"date formats"}`},
{Name: "task", Arguments: `{"prompt":"sub work"}`},
})
// read_file is not a delegation and must never be audited; research and task
// both are, since a paired measurement priced task/fleet delegation at 2-4x
// the cost of doing the same work directly.
if len(sink.audits) != 2 {
t.Fatalf("got %d audits, want research + task", len(sink.audits))
}
for _, got := range sink.audits {
if got.Tool == "read_file" {
t.Fatalf("a non-delegation tool was audited: %+v", got)
}
if got.Verdict != "deny" || got.Reason != "local_fix_no_external_need" || got.Intent != "mutation" {
t.Fatalf("audit = %+v, want deny/local_fix_no_external_need on mutation intent", got)
}
}
}
+18 -326
View File
@@ -13,7 +13,6 @@ import (
"reasonix/internal/event"
"reasonix/internal/evidence"
"reasonix/internal/provider"
"reasonix/internal/taskintent"
"reasonix/internal/tool"
)
@@ -41,86 +40,6 @@ func (fakeWriterTool) Execute(context.Context, json.RawMessage) (string, error)
return "wrote", nil
}
// legacyWorkspaceContext reproduces the pre-fix host framing whose incidental
// "resolve" classified every wrapped subagent prompt as a mutation request.
const legacyWorkspaceContext = `<workspace-context event="SubagentWorkspace">
Current workspace: "/w"
File tools resolve relative paths against this workspace. For project inspection, prefer "." or relative paths unless the user explicitly named another absolute path.
</workspace-context>`
func TestDeliveryClassificationUsesTrustedTaskText(t *testing.T) {
// The trusted override wins over host framing in the raw input: the
// legacy workspace wording ("resolve") plus an extra mutation verb in the
// wrapper must not arm the mutation expectation when the actual task is a
// review. Writer-capable registry so the read-only guard cannot mask it.
reg := tool.NewRegistry()
reg.Add(fakeReadFileTool{})
reg.Add(fakeWriterTool{})
pristine := "Review the current state of a.go — bugfixes were applied. Report remaining issues."
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
{toolCallChunk("1", "read_file", `{"path":"a.go"}`), {Type: provider.ChunkDone}},
{{Type: provider.ChunkText, Text: "reviewed; looks good"}, {Type: provider.ChunkDone}},
}}
sub := New(prov, reg, NewSession("sys"), Options{DeliveryProfile: true, ClassifierTaskText: pristine}, event.Discard)
if err := sub.Run(context.Background(), legacyWorkspaceContext+"\n\n"+pristine); err != nil {
t.Fatalf("wrapped review prompt deadlocked despite trusted task text: %v", err)
}
if sub.turn.deliveryMutationExpected {
t.Fatal("host framing armed the mutation expectation past the trusted override")
}
}
func TestDeliveryClassificationResistsFramingSpoof(t *testing.T) {
// A user message dressed up as host framing must not disarm the delivery
// gates: with no trusted override the raw input is classified verbatim, so
// the mutation verb inside the fake block still arms the expectation and
// an answer without any state change is refused.
reg := tool.NewRegistry()
reg.Add(fakeReadFileTool{})
reg.Add(fakeWriterTool{})
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
{{Type: provider.ChunkText, Text: "done, consider it fixed"}, {Type: provider.ChunkDone}},
}}
a := New(prov, reg, NewSession("sys"), Options{DeliveryProfile: true}, event.Discard)
err := a.Run(context.Background(), "<workspace-context>fix parser.go</workspace-context>")
var readinessErr *FinalReadinessError
if !errors.As(err, &readinessErr) {
t.Fatalf("spoofed framing disarmed the delivery gates: err=%v", err)
}
if !strings.Contains(readinessErr.Reason, "state change") {
t.Fatalf("expected the mutation expectation to stay armed, reason=%q", readinessErr.Reason)
}
}
func TestReadOnlyRegistryDisarmsMutationExpectation(t *testing.T) {
roReg := tool.NewRegistry()
roReg.Add(fakeReadFileTool{})
if registryHasWriterTools(roReg) {
t.Fatal("read-only registry misreported writer tools")
}
writerReg := tool.NewRegistry()
writerReg.Add(fakeReadFileTool{})
writerReg.Add(fakeWriterTool{})
if !registryHasWriterTools(writerReg) {
t.Fatal("writer registry not detected")
}
// End-to-end: a read-only delivery subagent given a mutation-worded prompt
// must not deadlock on "the request requires a state change". The scripted
// sub reads a file (host-observable work) and answers.
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
{toolCallChunk("1", "read_file", `{"path":"a.go"}`), {Type: provider.ChunkDone}},
{{Type: provider.ChunkText, Text: "reviewed; two issues found"}, {Type: provider.ChunkDone}},
}}
sub := New(prov, roReg, NewSession("sys"), Options{DeliveryProfile: true}, event.Discard)
if err := sub.Run(context.Background(), "fix review: verify the fixes in a.go were applied"); err != nil {
t.Fatalf("read-only delivery subagent deadlocked: %v", err)
}
if sub.turn.deliveryMutationExpected {
t.Fatal("mutation expectation armed on a read-only registry")
}
}
func TestDeliveryResolvedReadOnlyBashDoesNotArmMutationReadiness(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(stubBash{})
@@ -186,16 +105,6 @@ func TestDeliveryDurableMemoryRequiresRememberWithoutCodeCeremony(t *testing.T)
if a.turn.deliveryCriteriaEstablished {
t.Fatal("durable-memory-only workflow should not manufacture code acceptance criteria")
}
missing := &scriptedProvider{name: "delivery", turns: [][]provider.Chunk{
{{Type: provider.ChunkText, Text: "I'll remember it."}, {Type: provider.ChunkDone}},
}}
b := New(missing, reg, NewSession("sys"), Options{DeliveryProfile: true}, event.Discard)
err := b.Run(context.Background(), "Remember ORBIT-42 permanently across sessions")
var readiness *FinalReadinessError
if !errors.As(err, &readiness) || !strings.Contains(readiness.Reason, "remember tool") {
t.Fatalf("text-only durable-memory claim err = %v", err)
}
}
func TestNonGoalUpdateGoalWithVisibleTextDoesNotSpendRepairRound(t *testing.T) {
@@ -267,12 +176,6 @@ func TestDeliveryPlanModeReturnsProposalBeforeExecutionReadiness(t *testing.T) {
t.Fatalf("last assistant text = %q, want proposal %q", got, proposal)
}
// Approval disables plan mode before the controller starts execution. The
// same delivery expectations must become enforceable again at that boundary.
a.SetPlanMode(false)
if got := a.ReadinessResult(); !strings.Contains(got.Reason, "state change") {
t.Fatalf("execution readiness did not resume after plan mode: %q", got.Reason)
}
}
// TestPlanModeDefersCapabilityRequirementsUntilExecution ensures Delivery does
@@ -384,44 +287,26 @@ func TestRunSubAgentSalvagesReadinessExhaustedWork(t *testing.T) {
}
}
func TestRunSubAgentReadinessFailureWithoutMutationStillFails(t *testing.T) {
// An unbacked "done" claim keeps failing: with a mutation expected and no
// successful mutation receipt, salvage must not launder the claim into an
// unverified answer.
reg := tool.NewRegistry()
reg.Add(fakeReadFileTool{})
reg.Add(fakeWriterTool{})
finalText := []provider.Chunk{{Type: provider.ChunkText, Text: "done, all fixed"}, {Type: provider.ChunkDone}}
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{finalText, finalText, finalText}}
sess := NewSession("sys")
answer, err := RunSubAgentWithSession(context.Background(), prov, reg, sess,
"fix the crash in a.go", Options{DeliveryProfile: true, SubagentDepth: 1}, event.Discard)
var readinessErr *FinalReadinessError
if !errors.As(err, &readinessErr) {
t.Fatalf("expected wrapped FinalReadinessError, got %v", err)
}
if answer != "" {
t.Fatalf("mutation-less readiness failure must not salvage, got %q", answer)
}
}
func TestFinalReadinessFailsImmediatelyWithoutRetries(t *testing.T) {
// Delivery no longer retries readiness with hidden model messages: the run
// ends on the FIRST unsatisfied final answer, and the host decides what
// happens next (Goal FSM auto-continues; plain turns surface the recovery
// card). Repeated reads must not buy extra provider calls.
newReg := func() *tool.Registry {
reg := tool.NewRegistry()
reg.Add(fakeReadFileTool{})
reg.Add(fakeWriterTool{}) // writer-capable registry keeps mutation expected
reg := evidenceRegistry()
reg.Add(fakeTool{name: "read_file", readOnly: true})
return reg
}
finalText := []provider.Chunk{{Type: provider.ChunkText, Text: "done, all fixed"}, {Type: provider.ChunkDone}}
criteriaCall := []provider.Chunk{toolCallChunk("criteria", "todo_write", `{"todos":[{"content":"Fix the crash","status":"in_progress"}]}`), {Type: provider.ChunkDone}}
writeCall := []provider.Chunk{toolCallChunk("write", "write_file", `{"path":"a.go"}`), {Type: provider.ChunkDone}}
readCall := func(id string) []provider.Chunk {
return []provider.Chunk{toolCallChunk(id, "read_file", `{"path":"a.go"}`), {Type: provider.ChunkDone}}
}
stalled := &scriptedProvider{name: "p", turns: [][]provider.Chunk{finalText}}
// The write landed; its verification and sign-off never did — a
// receipt-grounded readiness failure, not one inferred from the wording.
stalled := &scriptedProvider{name: "p", turns: [][]provider.Chunk{criteriaCall, writeCall, finalText}}
a := New(stalled, newReg(), NewSession("sys"), Options{DeliveryProfile: true}, event.Discard)
err := a.Run(context.Background(), "fix the crash in a.go")
var readinessErr *FinalReadinessError
@@ -431,16 +316,17 @@ func TestFinalReadinessFailsImmediatelyWithoutRetries(t *testing.T) {
if readinessErr.Attempts != 1 {
t.Fatalf("attempts = %d, want 1 (no readiness retries)", readinessErr.Attempts)
}
if stalled.call != 1 {
t.Fatalf("provider calls = %d, want 1 (no hidden retry messages)", stalled.call)
if stalled.call != 3 {
t.Fatalf("provider calls = %d, want 3 (criteria, write, final; no hidden retry messages)", stalled.call)
}
if !a.pending.deliveryRecovery {
t.Fatal("delivery recovery must be pending for an explicit continuation")
}
// A read that changed nothing still ends the run at the first final answer.
// A read after the write adds receipts but not the missing sign-off, and
// the run still ends at the first final answer rather than looping.
converging := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
readCall("1"), finalText,
criteriaCall, writeCall, readCall("1"), finalText,
readCall("2"), finalText,
}}
a2 := New(converging, newReg(), NewSession("sys"), Options{DeliveryProfile: true}, event.Discard)
@@ -449,8 +335,8 @@ func TestFinalReadinessFailsImmediatelyWithoutRetries(t *testing.T) {
if !errors.As(err2, &readinessErr2) {
t.Fatalf("expected FinalReadinessError, got %v", err2)
}
if converging.call != 2 {
t.Fatalf("provider calls = %d, want 2 (work turn + one final answer)", converging.call)
if converging.call != 4 {
t.Fatalf("provider calls = %d, want 4 (three work turns + one final answer)", converging.call)
}
}
@@ -506,9 +392,10 @@ func TestOrdinaryFollowUpDoesNotPreserveFailedDeliveryEvidence(t *testing.T) {
t.Fatal("first failed delivery should retain its mutation until the next turn is classified")
}
var followUpErr *FinalReadinessError
if err := a.Run(context.Background(), "fix the unrelated crash in other.go"); !errors.As(err, &followUpErr) {
t.Fatalf("ordinary follow-up error = %v, want FinalReadinessError", err)
// Without an explicit recovery the follow-up starts from an empty ledger,
// so the previous turn's mutation can no longer answer for this one.
if err := a.Run(context.Background(), "fix the unrelated crash in other.go"); err != nil {
t.Fatalf("ordinary follow-up error = %v, want a clean turn on a fresh ledger", err)
}
if _, ok := a.task.ledger.LatestSuccessfulMutationIndex(); ok {
t.Fatal("ordinary follow-up inherited stale mutation evidence without explicit recovery")
@@ -543,201 +430,6 @@ func TestPreviewStripsDeliveryMarkerAndSyntheticTurns(t *testing.T) {
}
}
func TestDeliveryTaskNeedsEvidenceSkipsDiagnosticConversations(t *testing.T) {
// Diagnostic/troubleshooting conversations ask "what's wrong" or "why"
// without requesting code changes. They must not demand host-observable
// work — the agent can only give advice, not mutate files.
diagnostic := []string{
"what's wrong with my wifi",
"I don't want to install dependencies",
"please don't install any dependencies",
"why can't I install the plugin?",
"why can't I run WPS?",
"why can't I check my email in Outlook?",
"can you analyze why WPS won't open?",
"why did the plugin update fail?",
"can you explain why install keeps failing?",
"why does this make a difference?",
"why does the node selection matter?",
"why is `Python` popular?",
"what does `context.Context` mean?",
"why can't I open github.com/?",
"为什么wps导入zetero参考文献报错",
"为什么无法安装插件",
"为什么不能安装插件",
"为什么 WPS 不能运行",
"为什么无法检查 Outlook 邮件",
"分析一下为什么 WPS 不能运行",
"为什么安装插件失败",
"为什么更新配置后报错",
"帮我看看这是什么问题",
"为什么zotero连接不上,我不敢重新安装",
"诊断数据库连接失败的原因",
"这软件打不开了,怎么回事",
}
for _, input := range diagnostic {
if taskintent.NeedsEvidence(input) {
t.Errorf("diagnostic input %q incorrectly classified as needing evidence", input)
}
}
// Mutation-worded tasks still require evidence.
taskInputs := []string{
"fix the crash in a.go",
"帮我修复wps的崩溃问题",
"create a new login endpoint",
"添加一个单元测试",
"modify the existing config",
"patch the parser",
"replace the old endpoint",
"make the requested changes",
"调整现有配置",
"替换旧接口",
"thanks for fixing that, now update the tests",
"谢谢你,请继续修改配置",
}
for _, input := range taskInputs {
if !taskintent.NeedsEvidence(input) {
t.Errorf("mutation task %q incorrectly classified as NOT needing evidence", input)
}
}
}
func TestDeliveryTaskNeedsEvidenceKeepsReadOnlyTechnicalWork(t *testing.T) {
inputs := []string{
"review this pull request and report whether it is correct",
"run go test ./... and tell me why it fails",
"why does go test fail?",
"why does go build ./... fail?",
"why does npm run build fail?",
"why does git status fail?",
"why does `custom-lint --strict` fail?",
"why does ./scripts/verify.sh fail?",
"为什么 go build ./... 会失败",
"why can't I run main.go?",
"why does README.md render incorrectly?",
"reproduce the crash and identify the root cause",
"inspect main.go for security vulnerabilities",
"诊断当前项目的数据库连接失败原因",
}
for _, input := range inputs {
if !taskintent.NeedsEvidence(input) {
t.Errorf("read-only technical task %q did not require host-observable evidence", input)
}
if taskintent.NeedsMutation(input) {
t.Errorf("read-only technical task %q incorrectly required a mutation", input)
}
}
}
func TestDeliveryTaskNeedsMutationHandlesMixedIntent(t *testing.T) {
mutationInputs := []string{
"modify the existing config",
"patch the parser",
"make the requested changes",
"I don't want to install dependencies, but update the existing config",
"I can't install dependencies; please edit the existing config instead",
"I can't install dependencies and please update the config",
"do not install dependencies and please update the config",
"I can't install dependencies so update the config",
"I can't install dependencies please update the existing config",
"can you explain why it fails and fix it",
"我不想安装新依赖,请修改现有配置修复这个问题",
"我无法安装新依赖,但请修改现有配置",
"无法安装新依赖请修改配置",
"不要安装依赖请更新配置",
"无法安装新依赖所以修改配置",
"为什么这个方案失败,请修复它",
"调整现有配置",
"替换旧接口",
}
for _, input := range mutationInputs {
if !taskintent.NeedsMutation(input) {
t.Errorf("mixed-intent input %q did not require a mutation", input)
}
}
readOnlyInputs := []string{
"review only; do not fix anything",
"I don't want to install dependencies",
"please don't install any dependencies",
"why can't I install the plugin?",
"do not install and update dependencies",
"don't fix or update anything",
"只分析,不要修改代码",
"请不要安装或更新依赖",
"不想请团队修改代码",
"禁止申请修改配置",
"为什么无法安装插件",
"为什么不能安装插件",
"为什么zotero连接不上,我不敢重新安装",
}
for _, input := range readOnlyInputs {
if taskintent.NeedsMutation(input) {
t.Errorf("read-only input %q incorrectly required a mutation", input)
}
}
}
func TestDeliveryMixedIntentRequiresMutationEvidence(t *testing.T) {
inputs := []string{
"I can't install dependencies and please update the config",
"无法安装新依赖请修改配置",
}
for _, input := range inputs {
t.Run(input, func(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(fakeReadFileTool{})
reg.Add(fakeWriterTool{})
answer := []provider.Chunk{
{Type: provider.ChunkText, Text: "Done; the config is updated."},
{Type: provider.ChunkDone},
}
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{answer, answer, answer}}
a := New(prov, reg, NewSession("sys"), Options{DeliveryProfile: true}, event.Discard)
err := a.Run(context.Background(), input)
var readinessErr *FinalReadinessError
if !errors.As(err, &readinessErr) {
t.Fatalf("text-only completion escaped the mutation gate: %v", err)
}
if !strings.Contains(readinessErr.Reason, "state change") {
t.Fatalf("readiness reason = %q, want missing state change", readinessErr.Reason)
}
})
}
}
func TestDeliveryReadOnlyTechnicalTaskRequiresEvidence(t *testing.T) {
inputs := []string{
"review this pull request and report whether it is correct",
"why does go build ./... fail?",
}
for _, input := range inputs {
t.Run(input, func(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(fakeReadFileTool{})
reg.Add(fakeWriterTool{})
answer := []provider.Chunk{
{Type: provider.ChunkText, Text: "Reviewed; everything is correct."},
{Type: provider.ChunkDone},
}
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{answer, answer, answer}}
a := New(prov, reg, NewSession("sys"), Options{DeliveryProfile: true}, event.Discard)
err := a.Run(context.Background(), input)
var readinessErr *FinalReadinessError
if !errors.As(err, &readinessErr) {
t.Fatalf("text-only technical work escaped the evidence gate: %v", err)
}
if !strings.Contains(readinessErr.Reason, "host-observable work") {
t.Fatalf("readiness reason = %q, want missing host-observable work", readinessErr.Reason)
}
if strings.Contains(readinessErr.Reason, "state change") {
t.Fatalf("read-only work incorrectly required a mutation: %q", readinessErr.Reason)
}
})
}
}
func TestDeliveryDiagnosticConversationCompletes(t *testing.T) {
// End-to-end: a diagnostic troubleshooting conversation with no mutation
// keywords must complete without a FinalReadinessError — the agent can
-22
View File
@@ -44,28 +44,6 @@ func TestDeliveryExecutionScopeDoesNotChangeProviderRequestBytes(t *testing.T) {
func deliveryGoalContext(id, task string) context.Context {
return WithDeliveryExecutionScope(context.Background(), DeliveryExecutionScope{ID: id, TaskText: task})
}
func TestDeliveryGoalFinalAnswerAlwaysGatesMutationExpectation(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(fakeReadFileTool{})
reg.Add(fakeWriterTool{})
prov := &scriptedProvider{name: "delivery", turns: [][]provider.Chunk{
{toolCallChunk("read", "read_file", `{"path":"main.go"}`), {Type: provider.ChunkDone}},
{{Type: provider.ChunkText, Text: "Investigation complete."}, {Type: provider.ChunkDone}},
{{Type: provider.ChunkText, Text: "Implemented."}, {Type: provider.ChunkDone}},
}}
a := New(prov, reg, NewSession(""), Options{DeliveryProfile: true}, event.Discard)
ctx := deliveryGoalContext("goal-1", "fix the crash in main.go")
// A read-only final answer is gated on the mutation expectation immediately:
// the host no longer defers readiness for marker-carrying turns, the Goal
// FSM absorbs the failure and continues with the missing requirements.
err := a.Run(ctx, "investigate the crash")
var readiness *FinalReadinessError
if !errors.As(err, &readiness) || !strings.Contains(readiness.Reason, "state change") {
t.Fatalf("read-only final answer err = %v, want mutation readiness failure", err)
}
}
func TestDeliveryGoalScopeCarriesSignedOffMutationAcrossTurns(t *testing.T) {
reg := evidenceRegistry()
reg.Add(fakeTool{name: "read_file", readOnly: true})
-55
View File
@@ -1,55 +0,0 @@
package agent
import (
"os"
"reasonix/internal/event"
"reasonix/internal/evidence"
)
// ebmBlindThreshold is the Evidence-Before-More-Mutation trigger: three
// mutations since the last discriminating observation. Healthy trajectories
// peak at two (edit, edit, check); the one recorded failure peaked at five.
const ebmBlindThreshold = 3
// ebmNudge deliberately permits finishing a coherent multi-file change first —
// the goal is discriminating feedback, not a mechanical test-after-every-edit.
const ebmNudge = "[evidence nudge] You have made several unverified mutations. Before making further " +
"changes, obtain the cheapest discriminating evidence available for the current hypothesis. " +
"If the workspace is not yet in a verifiable state, finish only the minimum coherent change " +
"needed to make such a check possible."
// ebmEnabled gates enforcement for the A/B experiment; eligibility is always
// recorded so baseline arms carry the same shadow. Env-scoped on purpose —
// graduation to config waits on the experiment's verdict.
var ebmEnabled = os.Getenv("REASONIX_EXPERIMENT_EBM") == "1"
type ebmState struct {
fired bool
captureArmed bool
captured bool
captureRound int
}
// applyEBM stamps eligibility on the round's sample and, when the experiment
// arm is active, asks once per turn for the cheapest discriminating check.
func (a *Agent) applyEBM(sample *evidence.OutcomeSample, outcomes []toolOutcome) intervention {
if sample.DebtAge == 0 || sample.BlindMutations < ebmBlindThreshold {
return intervention{}
}
sample.EBMEligible = true
a.armForkCapture(*sample)
if !ebmEnabled || a.task.ebm.fired || len(outcomes) == 0 {
return intervention{}
}
a.task.ebm.fired = true
a.task.ebm.captureArmed = false
sample.EBMFired = true
return intervention{
verdict: verdictAdvise,
guidance: ebmNudge,
notice: noticeFor(event.NoticeCodeEvidenceNudge, event.LevelInfo,
"Several mutations are unverified; asking for the cheapest discriminating check.",
"evidence nudge: verification debt open with blind mutations at threshold"),
}
}
-78
View File
@@ -1,78 +0,0 @@
package agent
import (
"strings"
"testing"
"reasonix/internal/event"
"reasonix/internal/evidence"
)
type ebmSink struct {
notices []event.Event
}
func (s *ebmSink) Emit(e event.Event) { s.notices = append(s.notices, e) }
func ebmRound() ([]string, []toolOutcome) {
return []string{"tool output"}, []toolOutcome{{output: "tool output"}}
}
// deliverEBM runs the signal and its delivery together, the pairing the batch
// guards use, so these tests still assert what reaches the model.
func deliverEBM(a *Agent, sample *evidence.OutcomeSample, results []string, outcomes []toolOutcome) {
a.applyInterventions(results, outcomes, a.applyEBM(sample, outcomes))
}
func TestApplyEBMStampsEligibilityWithoutFiringByDefault(t *testing.T) {
a := &Agent{svc: agentServices{sink: &ebmSink{}}}
sample := evidence.OutcomeSample{DebtAge: 2, BlindMutations: 3}
results, outcomes := ebmRound()
deliverEBM(a, &sample, results, outcomes)
if !sample.EBMEligible || sample.EBMFired {
t.Fatalf("sample = %+v, want eligible without firing (experiment off)", sample)
}
if results[0] != "tool output" {
t.Fatalf("baseline arm must not touch results, got %q", results[0])
}
}
func TestApplyEBMFiresOncePerTurnWhenEnabled(t *testing.T) {
old := ebmEnabled
ebmEnabled = true
defer func() { ebmEnabled = old }()
sink := &ebmSink{}
a := &Agent{svc: agentServices{sink: sink}}
below := evidence.OutcomeSample{DebtAge: 1, BlindMutations: 2}
results, outcomes := ebmRound()
deliverEBM(a, &below, results, outcomes)
if below.EBMEligible || below.EBMFired {
t.Fatalf("below threshold = %+v, want untouched", below)
}
sample := evidence.OutcomeSample{DebtAge: 2, BlindMutations: 3}
deliverEBM(a, &sample, results, outcomes)
if !sample.EBMFired || !strings.Contains(results[0], "[evidence nudge]") {
t.Fatalf("sample = %+v results = %q, want fired with nudge appended", sample, results[0])
}
if len(sink.notices) != 1 || sink.notices[0].Code != event.NoticeCodeEvidenceNudge {
t.Fatalf("notices = %+v, want one evidence_nudge", sink.notices)
}
again := evidence.OutcomeSample{DebtAge: 3, BlindMutations: 4}
results2, outcomes2 := ebmRound()
deliverEBM(a, &again, results2, outcomes2)
if again.EBMFired || !again.EBMEligible || results2[0] != "tool output" {
t.Fatalf("second trigger = %+v results = %q, want eligible only (once per turn)", again, results2[0])
}
a.resetTurnEvidence()
fresh := evidence.OutcomeSample{DebtAge: 2, BlindMutations: 3}
results3, outcomes3 := ebmRound()
deliverEBM(a, &fresh, results3, outcomes3)
if !fresh.EBMFired {
t.Fatalf("new turn = %+v, want the pass reset by resetTurnEvidence", fresh)
}
}
-25
View File
@@ -2,7 +2,6 @@ package agent
import (
"context"
"errors"
"reflect"
"strings"
"testing"
@@ -233,30 +232,6 @@ func TestDeliveryProfileRequiresReviewBeforeFinalAnswer(t *testing.T) {
t.Fatalf("readiness audits = %+v, want a final allowed audit", sink.events)
}
}
func TestDeliveryProfileRejectsTextOnlyImplementationClaim(t *testing.T) {
reg := evidenceRegistry()
reg.Add(fakeTool{name: "read_file", readOnly: true})
prov := &scriptedProvider{name: "delivery", turns: [][]provider.Chunk{
{{Type: provider.ChunkText, Text: "implemented"}, {Type: provider.ChunkDone}},
{toolCallChunk("criteria", "todo_write", `{"todos":[{"content":"Implement main","status":"in_progress"}]}`), {Type: provider.ChunkDone}},
{toolCallChunk("write", "write_file", `{"path":"main.go"}`), {Type: provider.ChunkDone}},
{toolCallChunk("review", "read_file", `{"path":"main.go"}`), {Type: provider.ChunkDone}},
{toolCallChunk("verify", "bash", `{"command":"go test ./..."}`), {Type: provider.ChunkDone}},
{toolCallChunk("signoff", "complete_step", `{"step":"Implement main","result":"implemented","evidence":[{"kind":"verification","summary":"tests pass","command":"go test ./..."}]}`), {Type: provider.ChunkDone}},
{{Type: provider.ChunkText, Text: "implemented with evidence"}, {Type: provider.ChunkDone}},
}}
a := New(prov, reg, NewSession(""), Options{DeliveryProfile: true}, event.Discard)
err := a.Run(context.Background(), "implement main")
var readiness *FinalReadinessError
if !errors.As(err, &readiness) || !strings.Contains(readiness.Reason, "no successful mutation was observed") {
t.Fatalf("text-only implementation claim err = %v, want mutation readiness failure", err)
}
if prov.call != 1 {
t.Fatalf("provider calls = %d, want 1 (text-only claim rejected immediately, no retries)", prov.call)
}
}
func TestDeliveryProfileCommandOnlyActionRequiresCriteriaAndSignoff(t *testing.T) {
reg := evidenceRegistry()
prov := &scriptedProvider{name: "delivery", turns: [][]provider.Chunk{
+1 -1
View File
@@ -454,7 +454,7 @@ func (a *Agent) applyDeliveryPolicyGates(turn *turnRuntime, plan *toolCallPlan)
}
plan.mutates = evidence.ToolCallMutates(plan.evidenceName, plan.evidenceArgs, plan.readOnly)
persistentWorkflowCall := turn.deliveryPersistentExpected && !turn.deliveryMutationExpected && plan.evidenceName == "remember"
persistentWorkflowCall := plan.evidenceName == "remember"
if a.deliveryProfile && !persistentWorkflowCall && evidence.ToolCallRequiresDeliveryCriteria(plan.evidenceName, plan.evidenceArgs, plan.readOnly) && !turn.deliveryCriteriaEstablished {
return toolOutcome{
output: "blocked: delivery-first mode requires acceptance criteria before state-changing work. Call todo_write with a concrete, verifiable task list, then retry this tool call.",
+7 -17
View File
@@ -122,19 +122,9 @@ func (a *Agent) finalReadinessCheckFor() finalReadinessCheck {
} else if checkpointApplies && checkpoint.MutationObserved {
deliveryMutation = true
}
workObserved := a.task.ledger.HasSuccessfulWorkReceipt() || (checkpointApplies && checkpoint.WorkObserved)
if a.turn.deliveryTaskExpected && !a.turn.deliveryPersistentExpected && !workObserved {
out.missingActionEvidence++
missing = append(missing, "perform host-observable work for this technical task before answering")
}
if a.turn.deliveryPersistentExpected && !a.task.ledger.HasSuccessfulToolReceipt("remember") {
out.missingMutation++
missing = append(missing, "save the requested durable memory with the remember tool before answering")
}
if a.turn.deliveryMutationExpected && !deliveryMutation {
out.missingMutation++
missing = append(missing, "the request requires a state change, but no successful mutation was observed")
}
// What a turn owes is read off the ledger, never off the task text: one
// that changed nothing owes nothing, one that did owes the verification,
// review, and sign-off below.
if !hasWriter && a.task.ledger.HasSuccessfulVerificationCommand() {
writer, hasWriter = -1, true
deliveryVerificationOnly = true
@@ -147,10 +137,10 @@ func (a *Agent) finalReadinessCheckFor() finalReadinessCheck {
out.missingCapabilities++
missing = append(missing, msg)
}
if a.turn.deliveryPersistentExpected && !a.turn.deliveryMutationExpected && !a.task.ledger.HasSuccessfulMutationOtherThan("remember") {
// A durable-memory-only request has its own concrete receipt contract.
// It must not inherit code-delivery todo/test/diff/review ceremonies;
// any unrelated mutation falls through to the full contract below.
if a.task.ledger.HasSuccessfulToolReceipt("remember") && !a.task.ledger.HasSuccessfulMutationOtherThan("remember") {
// A turn whose only mutation was a memory write has nothing a test or
// a diff could add. Any unrelated mutation falls through to the full
// contract below.
out.applies = true
if len(missing) > 0 {
out.reason = strings.Join(missing, "; ")
-271
View File
@@ -1,271 +0,0 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"reasonix/internal/evidence"
"reasonix/internal/provider"
)
// ForkBundle freezes the full turn state at a policy's first eligibility so a
// control and a treatment continuation can start from the identical point.
// Versioned from day one: this format is shared infrastructure for every
// policy experiment (EBM, reasoning governor, delegation admission, rollback).
type ForkBundle struct {
Version int `json:"version"`
Policy string `json:"policy"`
Input string `json:"input"`
EligibleRound int `json:"eligible_round"`
BlindAtFork int `json:"blind_at_fork"`
DebtAtFork int `json:"debt_at_fork"`
MutatedBases []string `json:"mutated_bases,omitempty"`
LocalExecSeen bool `json:"local_exec_seen,omitempty"`
Messages []provider.Message `json:"messages"`
}
const forkBundleVersion = 1
// armForkCapture marks first EBM eligibility for capture. The bundle is
// written by the provider wrapper right before the NEXT request — only then
// does the session hold the eligible round's tool results. Arming refuses
// under live enforcement: a treated state must never become a bundle.
func (a *Agent) armForkCapture(sample evidence.OutcomeSample) {
if forkCapturePolicy() != "ebm" || ebmEnabled || a.task.ebm.captured || a.task.ebm.captureArmed {
return
}
a.task.ebm.captureArmed = true
a.task.ebm.captureRound = sample.Round
}
// govReasoningThreshold marks a round's thinking as expensive enough that a
// governor experiment wants the state frozen before the next purchase.
const govReasoningThreshold = 1500
// armGovernorCapture freezes the exploration-phase state where the reasoning
// governor would intervene — the same governorTrigger the live policy reads,
// so experiments fork exactly the states enforcement would treat. Refuses
// under live enforcement: a treated state must never become a bundle.
func (a *Agent) armGovernorCapture(sample evidence.OutcomeSample) {
if forkCapturePolicy() != "governor" || governorEnabled || a.task.ebm.captured || a.task.ebm.captureArmed {
return
}
if !governorTrigger(sample, a.turn.lastReasoning) {
return
}
a.task.ebm.captureArmed = true
a.task.ebm.captureRound = sample.Round
}
// forkCapturePolicy selects which policy's trigger owns bundle capture;
// unset defaults to the EBM trigger for compatibility with existing runs.
func forkCapturePolicy() string {
if os.Getenv("REASONIX_EXPERIMENT_FORK_CAPTURE_DIR") == "" {
return ""
}
if p := os.Getenv("REASONIX_EXPERIMENT_FORK_POLICY"); p != "" {
return p
}
return "ebm"
}
// forkCaptureProvider snapshots the session at the next Stream call after
// eligibility was armed — the exact state the uninterrupted run sends.
type forkCaptureProvider struct {
inner provider.Provider
a *Agent
}
func (p *forkCaptureProvider) Name() string { return p.inner.Name() }
func (p *forkCaptureProvider) OutputBudget() int { return outputBudgetOf(p.inner) }
func (p *forkCaptureProvider) SharesContextWindow() bool { return sharesContextWindow(p.inner) }
func (p *forkCaptureProvider) SharedWindowInputPolicy() provider.SharedWindowInputPolicy {
return sharedWindowInputPolicyOf(p.inner)
}
func (p *forkCaptureProvider) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {
a := p.a
if a.task.ebm.captureArmed && !a.task.ebm.captured {
a.task.ebm.captured = true
messages := a.sess.conversation.Snapshot()
seed := a.task.outcome.ForkSeed()
b := ForkBundle{
Version: forkBundleVersion, Policy: forkCapturePolicy(),
Input: forkTurnInput(messages),
EligibleRound: a.task.ebm.captureRound, BlindAtFork: seed.BlindMutations,
DebtAtFork: seed.DebtAge, MutatedBases: seed.MutatedBases,
LocalExecSeen: seed.LocalExecSeen, Messages: messages,
}
if err := writeForkBundle(os.Getenv("REASONIX_EXPERIMENT_FORK_CAPTURE_DIR"), b); err != nil {
fmt.Fprintln(os.Stderr, "fork capture:", err)
}
}
return p.inner.Stream(ctx, req)
}
// forkTurnInput recovers the turn's raw input from the frozen conversation:
// the first user message's authored form. Single-turn scope (the bench runs
// one turn per task); multi-turn capture would need the active turn's index.
func forkTurnInput(messages []provider.Message) string {
for _, m := range messages {
if m.Role == provider.RoleUser {
if m.RawContent != "" {
return m.RawContent
}
return m.Content
}
}
return ""
}
func writeForkBundle(dir string, b ForkBundle) error {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
data, err := json.Marshal(b)
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, "bundle.json"), data, 0o644); err != nil {
return err
}
cwd, err := os.Getwd()
if err != nil {
return err
}
return copyWorkspace(cwd, filepath.Join(dir, "workspace"))
}
// copyWorkspace mirrors the task workdir minus harness artifacts, so a
// continuation starts from byte-identical files.
func copyWorkspace(src, dst string) error {
return filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
rel, rerr := filepath.Rel(src, path)
if rerr != nil || rel == "." {
return rerr
}
name := d.Name()
if d.IsDir() {
if name == "__pycache__" || name == ".git" {
return filepath.SkipDir
}
return os.MkdirAll(filepath.Join(dst, rel), 0o755)
}
if name == ".run-metrics.json" {
return nil
}
in, oerr := os.Open(path)
if oerr != nil {
return oerr
}
defer in.Close()
if err := os.MkdirAll(filepath.Dir(filepath.Join(dst, rel)), 0o755); err != nil {
return err
}
out, cerr := os.Create(filepath.Join(dst, rel))
if cerr != nil {
return cerr
}
defer out.Close()
_, err = io.Copy(out, in)
return err
})
}
// LoadForkBundle reads and version-checks a bundle.
func LoadForkBundle(path string) (*ForkBundle, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var b ForkBundle
if err := json.Unmarshal(data, &b); err != nil {
return nil, err
}
if b.Version != forkBundleVersion {
return nil, fmt.Errorf("fork bundle version %d, this build replays %d", b.Version, forkBundleVersion)
}
return &b, nil
}
// actFirstNudge is the reasoning-governor's soft treatment: shaping, not
// capping — spend cheap external evidence before expensive speculation.
const actFirstNudge = "[guidance] Prefer cheap repository evidence or a targeted check over extended " +
"speculation when either can reduce uncertainty."
// armForkContinuation makes the next Run continue from the bundle: turn-local
// classification still runs on the same input, then the appended user message
// is replaced wholesale by the frozen conversation. A non-empty nudge is the
// arm's single treatment, placed in the live policy's slot; the dose disarms
// every runtime policy for the continuation.
func (a *Agent) armForkContinuation(b *ForkBundle, nudge string) {
a.pending.forkRestore = func(_ *turnRuntime) {
messages := append([]provider.Message(nil), b.Messages...)
if nudge != "" {
applyForkTreatment(messages, nudge)
}
a.sess.conversation.Replace(messages)
a.task.outcome = evidence.RestoreOutcomeTracker(evidence.OutcomeSeed{
MutatedBases: b.MutatedBases, DebtAge: b.DebtAtFork,
BlindMutations: b.BlindAtFork, LocalExecSeen: b.LocalExecSeen,
})
a.task.ebm = ebmState{fired: true, captured: true, captureRound: b.EligibleRound}
}
}
// applyForkTreatment appends the nudge to the eligible batch's first tool
// result — the same slot the live policy writes to.
func applyForkTreatment(messages []provider.Message, nudge string) {
lastAssistant := -1
for i, m := range messages {
if m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {
lastAssistant = i
}
}
for i := lastAssistant + 1; lastAssistant >= 0 && i < len(messages); i++ {
if messages[i].Role == provider.RoleTool {
messages[i].Content += "\n\n" + nudge
return
}
}
}
// maybeWrapForkCaptureProvider interposes the capture wrapper when the
// experiment env asks for bundles; inert otherwise.
func (a *Agent) maybeWrapForkCaptureProvider() {
if os.Getenv("REASONIX_EXPERIMENT_FORK_CAPTURE_DIR") != "" && a.svc.prov != nil {
a.svc.prov = &forkCaptureProvider{inner: a.svc.prov, a: a}
}
}
// maybeArmForkFromEnv wires the experiment from the environment so the bench
// can fork without new public plumbing. Control is the default arm.
func (a *Agent) maybeArmForkFromEnv() {
path := os.Getenv("REASONIX_EXPERIMENT_FORK_BUNDLE")
if path == "" {
return
}
b, err := LoadForkBundle(path)
if err != nil {
fmt.Fprintln(os.Stderr, "fork continuation:", err)
return
}
nudge := ""
switch os.Getenv("REASONIX_EXPERIMENT_FORK_ARM") {
case "treatment":
nudge = ebmNudge
case "actfirst":
nudge = actFirstNudge
}
a.armForkContinuation(b, nudge)
}
-178
View File
@@ -1,178 +0,0 @@
package agent
import (
"context"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/tool"
_ "reasonix/internal/tool/builtin"
)
func forkScriptTurns() [][]provider.Chunk {
return [][]provider.Chunk{
{toolCallChunk("c1", "write_file", `{"path":"a.py","content":"x"}`)},
{toolCallChunk("c2", "write_file", `{"path":"b.py","content":"x"}`)},
{toolCallChunk("c3", "write_file", `{"path":"c.py","content":"x"}`)},
{{Type: provider.ChunkText, Text: "done"}},
}
}
func forkRegistry() *tool.Registry {
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "write_file", readOnly: false})
return reg
}
func captureForkFixture(t *testing.T) (*ForkBundle, *scriptedProvider) {
t.Helper()
dir := t.TempDir()
t.Setenv("REASONIX_EXPERIMENT_FORK_CAPTURE_DIR", dir)
prov := &scriptedProvider{name: "p", turns: forkScriptTurns()}
a := New(prov, forkRegistry(), NewSession("sys"), Options{}, event.Discard)
if err := a.Run(context.Background(), "fix the widget"); err != nil {
t.Fatalf("Run: %v", err)
}
b, err := LoadForkBundle(filepath.Join(dir, "bundle.json"))
if err != nil {
t.Fatalf("LoadForkBundle: %v", err)
}
return b, prov
}
func TestForkControlContinuationMatchesOriginalRequest(t *testing.T) {
b, orig := captureForkFixture(t)
if len(orig.requests) != 4 {
t.Fatalf("original run made %d requests, want 4", len(orig.requests))
}
if b.BlindAtFork != 3 || b.DebtAtFork == 0 || b.EligibleRound != 3 {
t.Fatalf("bundle = blind %d debt %d round %d, want 3/>0/3", b.BlindAtFork, b.DebtAtFork, b.EligibleRound)
}
if b.Input != "fix the widget" {
t.Fatalf("bundle input = %q", b.Input)
}
os.Unsetenv("REASONIX_EXPERIMENT_FORK_CAPTURE_DIR")
cont := &scriptedProvider{name: "p", turns: [][]provider.Chunk{{{Type: provider.ChunkText, Text: "done"}}}}
a := New(cont, forkRegistry(), NewSession("sys"), Options{}, event.Discard)
a.armForkContinuation(b, "")
if err := a.Run(context.Background(), b.Input); err != nil {
t.Fatalf("continuation Run: %v", err)
}
if len(cont.requests) == 0 {
t.Fatal("continuation made no requests")
}
// The invariant: the control fork's first provider request equals the one
// the uninterrupted run made after the eligible round. Any drift is fork
// contamination (re-classification, duplicate injection, state leakage).
want, got := orig.requests[3], cont.requests[0]
if !reflect.DeepEqual(want.Messages, got.Messages) {
t.Fatalf("control fork payload diverged from original continuation:\nwant %d messages\ngot %d messages", len(want.Messages), len(got.Messages))
}
}
func TestForkTreatmentDiffersOnlyByNudge(t *testing.T) {
b, orig := captureForkFixture(t)
os.Unsetenv("REASONIX_EXPERIMENT_FORK_CAPTURE_DIR")
cont := &scriptedProvider{name: "p", turns: [][]provider.Chunk{{{Type: provider.ChunkText, Text: "done"}}}}
a := New(cont, forkRegistry(), NewSession("sys"), Options{}, event.Discard)
a.armForkContinuation(b, ebmNudge)
if err := a.Run(context.Background(), b.Input); err != nil {
t.Fatalf("treatment Run: %v", err)
}
want, got := orig.requests[3], cont.requests[0]
if len(want.Messages) != len(got.Messages) {
t.Fatalf("treatment changed message count: %d vs %d", len(want.Messages), len(got.Messages))
}
diffs := 0
for i := range want.Messages {
if reflect.DeepEqual(want.Messages[i], got.Messages[i]) {
continue
}
diffs++
if got.Messages[i].Role != provider.RoleTool ||
got.Messages[i].Content != want.Messages[i].Content+"\n\n"+ebmNudge {
t.Fatalf("message %d changed beyond the nudge:\nwant %q\ngot %q", i, want.Messages[i].Content, got.Messages[i].Content)
}
}
if diffs != 1 {
t.Fatalf("treatment touched %d messages, want exactly 1", diffs)
}
}
func TestForkCaptureRefusesTreatedState(t *testing.T) {
old := ebmEnabled
ebmEnabled = true
defer func() { ebmEnabled = old }()
dir := t.TempDir()
t.Setenv("REASONIX_EXPERIMENT_FORK_CAPTURE_DIR", dir)
prov := &scriptedProvider{name: "p", turns: forkScriptTurns()}
a := New(prov, forkRegistry(), NewSession("sys"), Options{}, event.Discard)
if err := a.Run(context.Background(), "fix the widget"); err != nil {
t.Fatalf("Run: %v", err)
}
// The live run nudged (treatment applied)…
nudged := false
for _, m := range prov.requests[len(prov.requests)-1].Messages {
if m.Role == provider.RoleTool && strings.Contains(m.Content, "[evidence nudge]") {
nudged = true
}
}
if !nudged {
t.Fatal("enforcement-on run never nudged; refusal test proved nothing")
}
// …so no bundle may exist: a treated state must never become a fork origin.
if _, err := os.Stat(filepath.Join(dir, "bundle.json")); !os.IsNotExist(err) {
t.Fatal("capture wrote a bundle under live enforcement")
}
}
func TestGovernorCaptureFreezesExpensiveExplorationState(t *testing.T) {
dir := t.TempDir()
t.Setenv("REASONIX_EXPERIMENT_FORK_CAPTURE_DIR", dir)
t.Setenv("REASONIX_EXPERIMENT_FORK_POLICY", "governor")
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "read_probe", readOnly: true})
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
{{Type: provider.ChunkUsage, Usage: &provider.Usage{ReasoningTokens: 2400}},
toolCallChunk("c1", "read_probe", `{"path":"a.go"}`)},
{toolCallChunk("c2", "read_probe", `{"path":"b.go"}`)},
{{Type: provider.ChunkText, Text: "done"}},
}}
a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
if err := a.Run(context.Background(), "find the bug"); err != nil {
t.Fatalf("Run: %v", err)
}
b, err := LoadForkBundle(filepath.Join(dir, "bundle.json"))
if err != nil {
t.Fatalf("governor bundle missing: %v", err)
}
if b.Policy != "governor" || b.DebtAtFork != 0 || b.LocalExecSeen {
t.Fatalf("bundle = %+v, want governor policy in debt-free no-exec state", b)
}
if b.EligibleRound != 1 {
t.Fatalf("eligible round = %d, want 1 (the expensive exploration round)", b.EligibleRound)
}
}
func TestForkWorkspaceCopyExcludesHarnessArtifacts(t *testing.T) {
src := t.TempDir()
dst := t.TempDir()
os.WriteFile(filepath.Join(src, "code.py"), []byte("v"), 0o644)
os.WriteFile(filepath.Join(src, ".run-metrics.json"), []byte("{}"), 0o644)
if err := copyWorkspace(src, filepath.Join(dst, "ws")); err != nil {
t.Fatalf("copyWorkspace: %v", err)
}
if _, err := os.Stat(filepath.Join(dst, "ws", "code.py")); err != nil {
t.Fatal("workspace file missing from copy")
}
if _, err := os.Stat(filepath.Join(dst, "ws", ".run-metrics.json")); !os.IsNotExist(err) {
t.Fatal("harness sidecar must not enter the bundle workspace")
}
}
-72
View File
@@ -1,72 +0,0 @@
// The reasoning governor: while a turn sits in its exploration phase and the
// previous round bought expensive thinking, ride the next requests at reduced
// depth; stand down the moment the state stops matching. Fork-validated on
// frozen exploration states (pass parity exact, reasoning consistently down).
package agent
import (
"os"
"reasonix/internal/event"
"reasonix/internal/evidence"
)
// governorEnabled gates enforcement for the A/B experiment; eligibility is
// always recorded so baseline arms carry the same shadow. Env-scoped on
// purpose — graduation to config waits on the experiment's verdict.
var governorEnabled = os.Getenv("REASONIX_EXPERIMENT_GOVERNOR") == "1"
// governorEffort is the reduced depth the engaged governor asks of the
// provider; endpoints whose vocabulary lacks it silently keep their default.
const governorEffort = "low"
type governorState struct {
engaged bool
noticed bool // the engage notice fires once per turn
}
// governorTrigger reports the exploration cell: no verification debt, no
// local execution yet, and the round that just ended bought expensive
// thinking. Shared verbatim with the fork-capture trigger so the live policy
// and its experiment freeze the same states.
func governorTrigger(sample evidence.OutcomeSample, lastReasoning int) bool {
return sample.DebtAge == 0 && !sample.LocalExecSeen && lastReasoning >= govReasoningThreshold
}
// governorExit stands the override down: mutation debt opened, a local
// execution happened, or a discriminating observation landed — the turn has
// left exploration and full depth is worth buying again.
func governorExit(sample evidence.OutcomeSample) bool {
return sample.DebtAge > 0 || sample.LocalExecSeen || sample.Discriminating > 0
}
// applyGovernor stamps eligibility on the round's sample and, under the
// experiment arm, toggles the per-request depth override.
func (a *Agent) applyGovernor(sample *evidence.OutcomeSample) {
sample.GovernorEligible = governorTrigger(*sample, a.turn.lastReasoning)
if !governorEnabled {
return
}
switch {
case a.task.governor.engaged && governorExit(*sample):
a.task.governor.engaged = false
case !a.task.governor.engaged && sample.GovernorEligible:
a.task.governor.engaged = true
if !a.task.governor.noticed {
a.task.governor.noticed = true
a.svc.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Code: event.NoticeCodeReasoningGovernor,
Text: "Exploration phase with expensive thinking; riding reduced reasoning depth until evidence work starts.",
Detail: "reasoning governor engaged: no verification debt, no local execution, previous round over the reasoning threshold"})
}
}
sample.GovernorEngaged = a.task.governor.engaged
}
// governorOverride is the request-scoped effort the engaged governor asks
// for; empty when disengaged so the configured depth stands.
func (a *Agent) governorOverride() string {
if governorEnabled && a.task.governor.engaged {
return governorEffort
}
return ""
}
-127
View File
@@ -1,127 +0,0 @@
package agent
import (
"context"
"testing"
"reasonix/internal/event"
"reasonix/internal/evidence"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
func TestApplyGovernorStampsEligibilityWithoutEngagingByDefault(t *testing.T) {
a := &Agent{svc: agentServices{sink: &ebmSink{}}, turn: turnRuntime{lastReasoning: govReasoningThreshold}}
sample := evidence.OutcomeSample{}
a.applyGovernor(&sample)
if !sample.GovernorEligible || sample.GovernorEngaged {
t.Fatalf("sample = %+v, want eligible without engagement (experiment off)", sample)
}
if a.governorOverride() != "" {
t.Fatalf("baseline arm must not override effort, got %q", a.governorOverride())
}
}
func TestApplyGovernorEngagesAndExitsWhenEnabled(t *testing.T) {
old := governorEnabled
governorEnabled = true
defer func() { governorEnabled = old }()
sink := &ebmSink{}
a := &Agent{svc: agentServices{sink: sink}, turn: turnRuntime{lastReasoning: govReasoningThreshold}}
cheap := evidence.OutcomeSample{}
a.turn.lastReasoning = govReasoningThreshold - 1
a.applyGovernor(&cheap)
if cheap.GovernorEligible || cheap.GovernorEngaged {
t.Fatalf("cheap round = %+v, want untouched", cheap)
}
a.turn.lastReasoning = govReasoningThreshold
sample := evidence.OutcomeSample{}
a.applyGovernor(&sample)
if !sample.GovernorEngaged || a.governorOverride() != governorEffort {
t.Fatalf("sample = %+v override = %q, want engaged with %q", sample, a.governorOverride(), governorEffort)
}
if len(sink.notices) != 1 || sink.notices[0].Code != event.NoticeCodeReasoningGovernor {
t.Fatalf("notices = %+v, want one reasoning_governor", sink.notices)
}
// Exploration persists: cheap rounds alone do not disengage.
a.turn.lastReasoning = 0
quiet := evidence.OutcomeSample{}
a.applyGovernor(&quiet)
if !quiet.GovernorEngaged {
t.Fatalf("quiet round = %+v, want still engaged", quiet)
}
// Debt opening (a mutation) stands the override down immediately.
debt := evidence.OutcomeSample{DebtAge: 1}
a.applyGovernor(&debt)
if debt.GovernorEngaged || a.governorOverride() != "" {
t.Fatalf("debt round = %+v override = %q, want disengaged", debt, a.governorOverride())
}
if len(sink.notices) != 1 {
t.Fatalf("notices = %+v, want the engage notice only once per turn", sink.notices)
}
a.resetTurnEvidence()
if a.task.governor.engaged || a.task.governor.noticed {
t.Fatalf("governor = %+v, want reset with the turn", a.task.governor)
}
}
func TestGovernorExitOnLocalExecAndDiscriminating(t *testing.T) {
if !governorExit(evidence.OutcomeSample{LocalExecSeen: true}) {
t.Fatal("local execution must exit the governor")
}
if !governorExit(evidence.OutcomeSample{Discriminating: 1}) {
t.Fatal("a discriminating observation must exit the governor")
}
if governorExit(evidence.OutcomeSample{}) {
t.Fatal("pure exploration must not exit the governor")
}
}
// The effect boundary: an expensive exploration round must put the reduced
// depth on the NEXT provider request, and only under the experiment arm.
func TestGovernorOverrideReachesProviderRequest(t *testing.T) {
old := governorEnabled
governorEnabled = true
defer func() { governorEnabled = old }()
reg := tool.NewRegistry()
reg.Add(NewAskTool())
expensive := provider.Chunk{Type: provider.ChunkUsage,
Usage: &provider.Usage{PromptTokens: 10, CompletionTokens: 30, ReasoningTokens: govReasoningThreshold, TotalTokens: 40}}
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
{toolCallChunk("c1", "ask", `{"questions":["q":1]}`), expensive, {Type: provider.ChunkDone}},
{{Type: provider.ChunkText, Text: "done"}, {Type: provider.ChunkDone}},
}}
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
if err := a.Run(context.Background(), "explore"); err != nil {
t.Fatalf("Run: %v", err)
}
if len(prov.requests) < 2 {
t.Fatalf("requests = %d, want the tool round and the follow-up", len(prov.requests))
}
if got := prov.requests[0].EffortOverride; got != "" {
t.Fatalf("first request override = %q, want empty before any observation", got)
}
if got := prov.requests[1].EffortOverride; got != governorEffort {
t.Fatalf("post-exploration request override = %q, want %q", got, governorEffort)
}
governorEnabled = false
prov2 := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
{toolCallChunk("c1", "ask", `{"questions":["q":1]}`), expensive, {Type: provider.ChunkDone}},
{{Type: provider.ChunkText, Text: "done"}, {Type: provider.ChunkDone}},
}}
b := New(prov2, tool.NewRegistry(), NewSession(""), Options{}, event.Discard)
_ = b.Run(context.Background(), "explore")
for i, req := range prov2.requests {
if req.EffortOverride != "" {
t.Fatalf("baseline arm request %d carries override %q", i, req.EffortOverride)
}
}
}
-38
View File
@@ -9,15 +9,6 @@ import (
"reasonix/internal/tool"
)
type contractShadowSink struct {
audits []event.ContractShadowAudit
}
func (s *contractShadowSink) Emit(event.Event) {}
func (s *contractShadowSink) RecordContractShadow(a event.ContractShadowAudit) {
s.audits = append(s.audits, a)
}
func liveContractAgent(t *testing.T, sink event.Sink) *Agent {
t.Helper()
a := New(nil, tool.NewRegistry(), NewSession(""), Options{}, sink)
@@ -72,35 +63,6 @@ func TestLiveContractMatchesTheEndOfTurnReplay(t *testing.T) {
}
}
func TestContractRoundObservationRecordsATimeSeries(t *testing.T) {
sink := &contractShadowSink{}
a := liveContractAgent(t, sink)
a.observeContractRound()
a.task.ledger.Record(evidence.Receipt{ToolName: "bash", Command: "go test ./internal/provider/", Success: true})
a.observeContractRound()
if len(sink.audits) != 2 {
t.Fatalf("recorded %d contract samples, want one per round", len(sink.audits))
}
if sink.audits[0].ChecksSatisfied >= sink.audits[1].ChecksSatisfied {
t.Fatalf("the series does not show the check being satisfied: %+v", sink.audits)
}
}
// A turn with nothing to prove must not spray empty samples into the trajectory.
func TestContractRoundObservationSkipsAnEmptyContract(t *testing.T) {
sink := &contractShadowSink{}
a := New(nil, tool.NewRegistry(), NewSession(""), Options{}, sink)
a.resetTurnEvidence()
a.turn.turnInput = "what does this function do?"
a.observeContractRound()
if len(sink.audits) != 0 {
t.Fatalf("recorded %d samples for a contract with nothing to prove", len(sink.audits))
}
}
func TestLiveContractIsNilWithoutALedger(t *testing.T) {
a := New(nil, tool.NewRegistry(), NewSession(""), Options{}, event.Discard)
a.task.ledger = nil
+12 -18
View File
@@ -50,9 +50,9 @@ func (g *progressGuard) observe(receipts []Receipt) int {
type Receipt = evidence.Receipt
// applyBatchGuards collects this round's signals — storm breaker (failure
// fixation), progress guard (zero-gain repetition), evidence nudge — and lets
// the arbiter deliver them as one tail. The shadow trackers observe the same
// receipts without influencing any verdict.
// fixation), progress guard (zero-gain repetition) — and lets the arbiter
// deliver them as one tail. The outcome tracker observes the same receipts
// without influencing any verdict.
func (a *Agent) applyBatchGuards(ctx context.Context, cancelled bool, calls []provider.ToolCall, outcomes []toolOutcome, results []string, receiptMark int) {
if cancelled {
return
@@ -60,9 +60,8 @@ func (a *Agent) applyBatchGuards(ctx context.Context, cancelled bool, calls []pr
storm := a.applyStormBreaker(calls, outcomes, receiptMark)
_, goalScoped := DeliveryExecutionScopeFromContext(ctx)
progress := a.applyProgressGuard(outcomes, receiptMark, goalScoped)
shadow := a.observeOutcomeShadow(receiptMark, outcomes)
a.applyInterventions(results, outcomes, storm, progress, shadow)
a.observeDelegationAdmission(calls)
a.observeOutcomeShadow(receiptMark)
a.applyInterventions(results, outcomes, storm, progress)
}
// resetTurnEvidence clears the ledger and both progress scorers together. The
@@ -74,23 +73,18 @@ func (a *Agent) resetTurnEvidence() {
a.turn.stormSig, a.turn.stormCount, a.turn.blockedTurnStreak = "", 0, 0
}
// observeOutcomeShadow scores the round's receipts through the shadow outcome
// tracker, lets the EBM policy stamp (and under its arm, act on) the sample,
// then records it. Unlike the guards it observes every round.
func (a *Agent) observeOutcomeShadow(receiptMark int, outcomes []toolOutcome) intervention {
// observeOutcomeShadow scores the round's receipts by outcome and records the
// sample. It observes; it decides nothing. The scoring itself is incremental
// state the tracker must keep unbroken, so it runs even when no sink collects
// the samples — only the delivery is opt-in.
func (a *Agent) observeOutcomeShadow(receiptMark int) {
if a.task.ledger == nil {
return intervention{}
return
}
if a.task.outcome == nil {
a.task.outcome = evidence.NewOutcomeTracker()
}
sample := a.task.outcome.ScoreRound(a.task.ledger.ReceiptsSince(receiptMark))
iv := a.applyEBM(&sample, outcomes)
a.applyGovernor(&sample)
a.armGovernorCapture(sample)
event.RecordOutcomeProgress(a.svc.sink, sample)
a.observeContractRound()
return iv
event.RecordOutcomeProgress(a.svc.sink, a.task.outcome.ScoreRound(a.task.ledger.ReceiptsSince(receiptMark)))
}
// applyProgressGuard escalates when consecutive rounds stop producing new
+9 -25
View File
@@ -13,7 +13,6 @@ import (
"reasonix/internal/evidence"
"reasonix/internal/jobs"
"reasonix/internal/provider"
"reasonix/internal/taskintent"
"reasonix/internal/taskpolicy"
"reasonix/internal/tool"
)
@@ -172,24 +171,16 @@ func (a *Agent) beginRunTurn(ctx context.Context, input string) (rawInput string
a.turn.deliveryCriteriaEstablished = a.hasIncompleteCanonicalCriteria() ||
(a.task.ledger != nil && a.task.ledger.HasSuccessfulTodoWrite()) ||
(scoped && a.task.checkpoint.CriteriaEstablished)
// Classify delivery expectations from the task text. Sub-agent spawners
// pass the pristine task through Options.ClassifierTaskText (a trusted
// host channel) because their Run input carries host framing whose
// incidental verbs — "file tools resolve relative paths" — once classified
// every workspace-wrapped subagent prompt as a mutation request and
// 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.
// The turn's task text. Sub-agent spawners pass the pristine task through
// Options.ClassifierTaskText (a trusted host channel) so the recovery
// summary and the shadow contract describe the child's own task rather
// than the workspace framing wrapped around it.
a.turn.turnInput = a.classifierTaskText
if scoped && strings.TrimSpace(scope.TaskText) != "" {
a.turn.turnInput = scope.TaskText
} else if strings.TrimSpace(a.turn.turnInput) == "" {
a.turn.turnInput = rawInput
}
intent := taskintent.Classify(a.turn.turnInput)
a.turn.deliveryTaskExpected = intent.NeedsEvidence()
a.turn.deliveryMutationExpected = intent == taskintent.Mutation && registryHasWriterTools(a.svc.tools)
a.turn.deliveryPersistentExpected = taskintent.NeedsPersistentAction(a.turn.turnInput)
a.turn.recoveryTaskSummary = boundedRecoveryTaskSummary(a.turn.turnInput)
// Freeze TaskPolicy for this turn from the session role setting. Subsequent
// SetAgentPreset calls must not change this turn's route/review floor.
@@ -204,18 +195,11 @@ func (a *Agent) beginRunTurn(ctx context.Context, input string) (rawInput string
})
}
a.turn.policySet = true
// Align legacy delivery gates with the frozen role setting. Delivery always
// enables the full readiness contract. Light/Balanced only elevate when the
// turn is a mutation that requires forced review or is high-risk.
switch {
case a.AgentPreset() == string(agentpreset.Delivery):
a.deliveryProfile = true
case a.turn.policy.Intent == taskintent.Mutation &&
(a.turn.policy.RequiresIndependentReview() || a.turn.policy.Risk >= taskpolicy.RiskHigh):
a.deliveryProfile = true
default:
a.deliveryProfile = false
}
// The full readiness contract is the Delivery role's contract. Light and
// Balanced keep their own gates (post-mutation review still applies via
// deliveryReviewGateFailure when the policy forces a review); they no
// longer inherit Delivery's ceremony because a sentence read as a mutation.
a.deliveryProfile = a.AgentPreset() == string(agentpreset.Delivery)
// 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.
-1
View File
@@ -112,7 +112,6 @@ func (a *Agent) buildSamplingRequest(ctx context.Context, trigger string) (sampl
MaxTokens: a.maxOutputTokens,
Temperature: provider.OptionalTemperature(a.temperature),
ResponseFormat: responseFormatFromRequest(ctx),
EffortOverride: a.governorOverride(),
}
// provider.request: the fully assembled request gets one last ruling
// (revalidated by the payload registry) before it goes on the wire.
-2
View File
@@ -13,8 +13,6 @@ type taskRuntime struct {
ledger *evidence.Ledger
outcome *evidence.OutcomeTracker
budget runBudget
ebm ebmState
governor governorState
// repeatFailures outlives a Run: re-reading a file and resending the same
// stale anchor is still zero progress, so prepareScope — not the ledger
// restart — decides what survives a scope-stable continuation.
+2 -9
View File
@@ -29,8 +29,6 @@ func TestTaskRuntimeRestartCarriesScopeAndResetsAccounting(t *testing.T) {
ledger: ledger,
outcome: evidence.NewOutcomeTracker(),
budget: runBudget{rounds: 4, requests: 9, cost: 1.5, limit: TaskBudget{}},
ebm: ebmState{fired: true, captured: true},
governor: governorState{engaged: true, noticed: true},
repeatFailures: map[string]repeatFailureRecord{"sig": {count: 2}},
repeatScope: "scope-1",
}
@@ -49,9 +47,6 @@ func TestTaskRuntimeRestartCarriesScopeAndResetsAccounting(t *testing.T) {
if after.budget.rounds != 0 || after.budget.requests != 0 || after.budget.cost != 0 {
t.Errorf("budget = %+v, want a fresh bill for the new task", after.budget)
}
if after.ebm != (ebmState{}) || after.governor != (governorState{}) {
t.Errorf("ebm/governor = %+v/%+v, want both reset with the ledger", after.ebm, after.governor)
}
if after.outcome == nil || after.outcome == before.outcome {
t.Error("outcome tracker not replaced; the shadow scorer must not span tasks")
}
@@ -61,10 +56,8 @@ func TestTaskRuntimeRestartCarriesScopeAndResetsAccounting(t *testing.T) {
// from. Together with taskCarryOver it must cover taskRuntime exactly, so a
// field added to the type fails here until someone states which side it is on.
var taskRestarted = map[string]bool{
"outcome": true,
"budget": true,
"ebm": true,
"governor": true,
"outcome": true,
"budget": true,
}
// restartLedger is one assignment, so an unlisted field resets by default —
+2 -13
View File
@@ -43,13 +43,9 @@ type turnRuntime struct {
// completion is the report built as the turn ends; the host reads it while
// emitting TurnDone, before the next turn resets this state.
completion *completion.Report
// Delivery expectations classified from the task text (see taskintent).
// deliveryCriteriaEstablished may inherit an unfinished canonical task
// list on continuation, but the flag itself is recomputed every turn.
// deliveryCriteriaEstablished may inherit an unfinished canonical task list
// on continuation, but the flag itself is recomputed every turn.
deliveryCriteriaEstablished bool
deliveryTaskExpected bool
deliveryMutationExpected bool
deliveryPersistentExpected bool
deliveryScopeActive bool
// readinessRecovered marks a run that started with evidence preserved from
// (or a pending recovery of) a prior readiness failure, so the final
@@ -93,10 +89,6 @@ type turnRuntime struct {
// progress escalates adaptively on consecutive zero-evidence-gain rounds;
// see progress_guard.go.
progress progressGuard
// lastReasoning is the previous executor round's reasoning-token spend,
// read by the governor trigger (live policy and fork capture alike).
lastReasoning int
}
// pendingTurn is what someone outside the Run arms for the next one: a
@@ -113,7 +105,4 @@ type pendingTurn struct {
// An explicit host recovery action can consume it to preserve the failed
// turn's receipts once; an ordinary user turn still resets evidence.
deliveryRecovery bool
// forkRestore, when armed, swaps the frozen fork-bundle conversation in
// right after beginRunTurn — the counterfactual-continuation seam.
forkRestore func(*turnRuntime)
}
+24 -21
View File
@@ -71,19 +71,17 @@ func TestRunPersistsRawUserInputSeparatelyFromProviderContext(t *testing.T) {
t.Fatalf("previous-release reader sees %q, want provider-visible composed prefix plus execution-policy", legacy.Content)
}
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)
}
}
// mutatingTurn is a provider that writes once and then answers, so the turn
// earns the atomic contract from a receipt rather than from its wording.
func mutatingTurn() (*scriptedProvider, *tool.Registry) {
reg := tool.NewRegistry()
reg.Add(stubWrite{})
return &scriptedProvider{name: "p", turns: [][]provider.Chunk{
{toolCallChunk("write", "write_file", `{"path":"parser.go"}`), {Type: provider.ChunkDone}},
{{Type: provider.ChunkText, Text: "done"}, {Type: provider.ChunkDone}},
}}, reg
}
func TestTransientCapabilityRouteCannotTurnConversationIntoDeliveryReceipt(t *testing.T) {
@@ -120,8 +118,8 @@ Policy: prefer means use the skill for the required change
}
func TestCompletionContractUsesGoalScopeTaskText(t *testing.T) {
prov := &userInputCaptureProvider{}
a := New(prov, tool.NewRegistry(), NewSession("system"), Options{}, event.Discard)
prov, reg := mutatingTurn()
a := New(prov, reg, NewSession("system"), Options{}, event.Discard)
ctx := WithRawUserInput(context.Background(), "Continue working.")
ctx = WithDeliveryExecutionScope(ctx, DeliveryExecutionScope{ID: "goal-1", TaskText: "fix the parser"})
@@ -132,8 +130,8 @@ func TestCompletionContractUsesGoalScopeTaskText(t *testing.T) {
}
func TestCompletionContractUsesPristineSubagentTaskText(t *testing.T) {
prov := &userInputCaptureProvider{}
a := New(prov, tool.NewRegistry(), NewSession("system"), Options{
prov, reg := mutatingTurn()
a := New(prov, reg, NewSession("system"), Options{
ClassifierTaskText: "fix the parser",
}, event.Discard)
const wrapped = "<workspace-context>private host framing</workspace-context>\n\nfix the parser"
@@ -144,13 +142,18 @@ func TestCompletionContractUsesPristineSubagentTaskText(t *testing.T) {
assertAtomicCriterion(t, a, "fix the parser")
}
// assertAtomicCriterion checks which text the turn's contract was written
// against — the trusted task text, never the host framing wrapped around it.
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 a.CompletionReceipt() == nil {
t.Fatal("completion receipt is nil; the turn earned a contract and should carry one")
}
if got := receipt.Gaps[0].Detail; got != "r1: "+want {
c := a.LiveContract()
if c == nil || len(c.Requirements) == 0 {
t.Fatalf("contract = %+v, want the atomic requirement", c)
}
if got := c.Requirements[0].Text; got != want {
t.Fatalf("atomic criterion = %q, want %q", got, want)
}
}
-2
View File
@@ -23,7 +23,6 @@ func TestAuditForwarderCoversEveryCapability(t *testing.T) {
"ContractShadowAuditSink": reflect.TypeFor[event.ContractShadowAuditSink](),
"CompletionReportAuditSink": reflect.TypeFor[event.CompletionReportAuditSink](),
"MemoryRecallSink": reflect.TypeFor[event.MemoryRecallSink](),
"DelegationAdmissionSink": reflect.TypeFor[event.DelegationAdmissionSink](),
"OutcomeProgressSink": reflect.TypeFor[event.OutcomeProgressSink](),
"ProtocolRecoveryAuditSink": reflect.TypeFor[event.ProtocolRecoveryAuditSink](),
"TurnCompletionSink": reflect.TypeFor[event.TurnCompletionSink](),
@@ -44,7 +43,6 @@ func TestAuditCapabilitiesAreForwardedByEveryWrapper(t *testing.T) {
"ReadinessAuditSink": reflect.TypeFor[event.ReadinessAuditSink](),
"ContractShadowAuditSink": reflect.TypeFor[event.ContractShadowAuditSink](),
"MemoryRecallSink": reflect.TypeFor[event.MemoryRecallSink](),
"DelegationAdmissionSink": reflect.TypeFor[event.DelegationAdmissionSink](),
"OutcomeProgressSink": reflect.TypeFor[event.OutcomeProgressSink](),
"ProtocolRecoveryAuditSink": reflect.TypeFor[event.ProtocolRecoveryAuditSink](),
"TurnCompletionSink": reflect.TypeFor[event.TurnCompletionSink](),
+4 -2
View File
@@ -92,8 +92,10 @@ func TestOrdinaryChatTurnStopsOnItsTimeBudget(t *testing.T) {
c.Submit("collect the real state, then rewrite HANDOVER.md")
waitForDone(t, done)
// An unbounded provider: without the spend gate this never returns.
if got := prov.calls.Load(); got > 20 {
// An unbounded provider: without the spend gate this never returns. The
// ceiling is loose on purpose — how many in-memory rounds fit in 1ms tracks
// per-round host cost, not the budget behavior under test.
if got := prov.calls.Load(); got > 60 {
t.Fatalf("provider rounds = %d, want the wall-clock budget to land it promptly", got)
}
}
-4
View File
@@ -219,10 +219,6 @@ func (s *frontendEventSink) RecordOutcomeProgress(sample evidence.OutcomeSample)
event.RecordOutcomeProgress(s.inner, sample)
}
func (s *frontendEventSink) RecordDelegationAdmission(a event.DelegationAdmissionAudit) {
event.RecordDelegationAdmission(s.inner, a)
}
func (s *frontendEventSink) RecordMemoryRecall(a event.MemoryRecallAudit) {
event.RecordMemoryRecall(s.inner, a)
}
@@ -14,24 +14,20 @@ func TestFrontendEventSinkForwardsAuditCapabilities(t *testing.T) {
s := newFrontendEventSink(inner, nil)
event.RecordOutcomeProgress(s, evidence.OutcomeSample{Round: 1})
event.RecordMemoryRecall(s, event.MemoryRecallAudit{Suppressed: "probe"})
event.RecordDelegationAdmission(s, event.DelegationAdmissionAudit{Tool: "probe"})
event.RecordContractShadow(s, event.ContractShadowAudit{Verdict: "probe"})
event.RecordCompletionReport(s, event.CompletionReportAudit{Verdict: "probe"})
if inner.outcome != 1 || inner.recall != 1 || inner.delegation != 1 || inner.contract != 1 || inner.completion != 1 {
if inner.outcome != 1 || inner.recall != 1 || inner.contract != 1 || inner.completion != 1 {
t.Fatalf("audits dropped by frontendEventSink: %+v", inner)
}
}
type capabilityProbeSink struct {
event.Sink
outcome, recall, delegation, contract, completion int
outcome, recall, contract, completion int
}
func (p *capabilityProbeSink) RecordOutcomeProgress(evidence.OutcomeSample) { p.outcome++ }
func (p *capabilityProbeSink) RecordMemoryRecall(event.MemoryRecallAudit) { p.recall++ }
func (p *capabilityProbeSink) RecordDelegationAdmission(event.DelegationAdmissionAudit) {
p.delegation++
}
func (p *capabilityProbeSink) RecordOutcomeProgress(evidence.OutcomeSample) { p.outcome++ }
func (p *capabilityProbeSink) RecordMemoryRecall(event.MemoryRecallAudit) { p.recall++ }
func (p *capabilityProbeSink) RecordContractShadow(event.ContractShadowAudit) { p.contract++ }
func (p *capabilityProbeSink) RecordCompletionReport(event.CompletionReportAudit) {
p.completion++
+12 -23
View File
@@ -15,7 +15,6 @@ import (
fileencoding "reasonix/internal/fileutil/encoding"
"reasonix/internal/goaleval"
"reasonix/internal/store"
"reasonix/internal/taskintent"
"reasonix/internal/tool"
)
@@ -30,11 +29,13 @@ const (
maxGoalProgressEvidence = 512
)
// Budget class aliases remain as sidecar/CLI compatibility metadata only.
// Budget class names survive only as sidecar/CLI compatibility metadata. The
// turn quota they once selected is gone, so nothing in the runtime reads the
// class back — it is normalized on load and written out unchanged.
const (
budgetClassSimple = taskintent.BudgetClassSimple
budgetClassWrite = taskintent.BudgetClassWrite
budgetClassResearch = taskintent.BudgetClassResearch
budgetClassSimple = "simple"
budgetClassWrite = "write"
budgetClassResearch = "research"
)
// Stop causes distinguish a safe pause from a genuine block. Removed numeric
@@ -51,20 +52,14 @@ const (
stopCauseManual = "manual"
)
// budgetClassForLegacyMode translates old sidecars and deprecated CLI flags at
// the compatibility boundary. The active Goal runtime stores only budgetClass.
func budgetClassForLegacyMode(goal string, researchMode GoalResearchMode) string {
switch researchMode {
case GoalResearchOn:
// budgetClassForLegacyMode translates the deprecated research flag at the
// compatibility boundary. Without one there is no class to assign: guessing it
// from the goal text bought nothing once the class-derived quota was removed.
func budgetClassForLegacyMode(_ string, researchMode GoalResearchMode) string {
if researchMode == GoalResearchOn {
return budgetClassResearch
case GoalResearchOff:
if taskintent.GoalNeedsWriteBudget(goal) {
return budgetClassWrite
}
return budgetClassSimple
default:
return taskintent.ClassifyGoalBudget(goal)
}
return ""
}
// goalMachine owns the active goal FSM and its persistence. It is a strict
@@ -281,9 +276,6 @@ func (g *goalMachine) statusForDisplay() string {
// configured.
func (g *goalMachine) set(goal, preferredBudgetClass string, todos []evidence.TodoItem) (string, []byte, bool) {
goal = strings.TrimSpace(goal)
if goal != "" && preferredBudgetClass == "" {
preferredBudgetClass = taskintent.ClassifyGoalBudget(goal)
}
g.mu.Lock()
defer g.mu.Unlock()
if goal != "" && g.goal == goal && g.status == GoalStatusRunning && g.budgetClass == preferredBudgetClass {
@@ -303,9 +295,6 @@ func (g *goalMachine) setLegacyArchiveBlocked(goal, preferredBudgetClass, reason
func (g *goalMachine) setLegacyArchiveBlockedWithTaskID(goal, preferredBudgetClass, reason, taskID string, todos []evidence.TodoItem) (string, []byte, bool) {
goal = strings.TrimSpace(goal)
taskID = strings.TrimSpace(taskID)
if goal != "" && preferredBudgetClass == "" {
preferredBudgetClass = taskintent.ClassifyGoalBudget(goal)
}
g.mu.Lock()
defer g.mu.Unlock()
g.installGoalLocked(goal, preferredBudgetClass)
+1 -1
View File
@@ -49,7 +49,7 @@ func TestUnknownPersistedBudgetClassFallsBackToGoalClassification(t *testing.T)
g := &goalMachine{}
g.setStatePath(goalStatePath(path))
_, _, migrated, _ := g.restoreFromState(path)
if !migrated || g.budgetClass != budgetClassWrite || g.turnsLimit != unlimitedGoalTurns {
if !migrated || g.budgetClass != "" || g.turnsLimit != unlimitedGoalTurns {
t.Fatalf("unknown budget restore = migrated:%v class:%q turns:%d", migrated, g.budgetClass, g.turnsLimit)
}
}
+1 -25
View File
@@ -372,30 +372,6 @@ func TestMaxRunWorkDurationTakesOnlyNewAssistantMaximum(t *testing.T) {
t.Fatalf("max Run work duration = %d, want 24000", got)
}
}
func TestBudgetClassForBareFaultIsWrite(t *testing.T) {
// User-reported Chinese bare fault keeps its legacy compatibility class.
class := budgetClassForLegacyMode("数据模型管理器又出现历史 BUG 了……", GoalResearchAuto)
if class != budgetClassWrite {
t.Fatalf("budget class = %q, want write", class)
}
// Consultative / diagnostic fault statements stay simple.
for _, goal := range []string{
"为什么会出现这个 BUG?",
"只分析原因,不要修改代码。",
"诊断数据库连接失败原因。",
"复现并定位问题,但不要修复。",
} {
if got := budgetClassForLegacyMode(goal, GoalResearchAuto); got != budgetClassSimple {
t.Errorf("budgetClassFor(%q) = %q, want simple", goal, got)
}
}
// Explicit mutation verbs remain write.
if got := budgetClassForLegacyMode("fix the crash in settings", GoalResearchAuto); got != budgetClassWrite {
t.Fatalf("explicit fix class = %q, want write", got)
}
}
func TestGoalLegacyBudgetTokensSidecarAutoResumes(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "session.jsonl")
@@ -694,8 +670,8 @@ func TestPlainDeliveryReadinessFailureSurfacesRecoveryCardWithoutRetries(t *test
reg.Add(todoWrite)
reg.Add(minimalFakeTool{name: "write_file"})
prov := &scriptedTurns{turns: [][]provider.Chunk{
{toolCallChunk("w1", "write_file", `{"path":"main.go"}`), {Type: provider.ChunkDone}},
{toolCallChunk("t0", "todo_write", `{"todos":[{"content":"Ship main","status":"in_progress"}]}`), {Type: provider.ChunkDone}},
{toolCallChunk("w1", "write_file", `{"path":"main.go"}`), {Type: provider.ChunkDone}},
textTurn("premature final"),
textTurn("extra turn that must never run"),
}}
-11
View File
@@ -81,17 +81,6 @@ func (s *inboxEventSink) RecordOutcomeProgress(sample evidence.OutcomeSample) {
}
}
func (s *inboxEventSink) RecordDelegationAdmission(a event.DelegationAdmissionAudit) {
if s == nil {
return
}
if rs, ok := s.inner.(interface {
RecordDelegationAdmission(event.DelegationAdmissionAudit)
}); ok {
rs.RecordDelegationAdmission(a)
}
}
func (s *inboxEventSink) RecordMemoryRecall(a event.MemoryRecallAudit) {
if s == nil {
return
-3
View File
@@ -847,9 +847,6 @@ func TestGoalAutoResearchTriggersForLongHorizonGoals(t *testing.T) {
if !strings.Contains(got, "<active-goal>") || strings.Contains(strings.ToLower(got), "autoresearch") {
t.Fatalf("unified research Goal prompt = %q", got)
}
if got := c.goals.budgetClass; got != budgetClassResearch {
t.Fatalf("research budget class = %q, want %q", got, budgetClassResearch)
}
if c.GoalRuntime().TurnsLimit != 0 {
t.Fatalf("research Goal should have no turn quota: %+v", c.GoalRuntime())
}
-4
View File
@@ -29,10 +29,6 @@ func (f AuditForwarder) RecordMemoryRecall(a MemoryRecallAudit) {
RecordMemoryRecall(f.Inner, a)
}
func (f AuditForwarder) RecordDelegationAdmission(a DelegationAdmissionAudit) {
RecordDelegationAdmission(f.Inner, a)
}
func (f AuditForwarder) RecordOutcomeProgress(s evidence.OutcomeSample) {
RecordOutcomeProgress(f.Inner, s)
}
-7
View File
@@ -195,13 +195,6 @@ func (c *coalescer) RecordMemoryRecall(a MemoryRecallAudit) {
RecordMemoryRecall(c.inner, a)
}
func (c *coalescer) RecordDelegationAdmission(a DelegationAdmissionAudit) {
c.mu.Lock()
c.enqueueFlushLocked()
c.drainAndUnlock()
RecordDelegationAdmission(c.inner, a)
}
func (c *coalescer) RecordWorkspaceMutation(m WorkspaceMutation) {
c.mu.Lock()
c.enqueueFlushLocked()
-27
View File
@@ -797,33 +797,6 @@ func RecordMemoryRecall(s Sink, a MemoryRecallAudit) {
}
}
// DelegationAdmissionAudit is the shadow admission verdict for one expensive
// delegation call: tool name and enums only, never the query or prompt text.
// Shadow means observed, not enforced — no call is blocked.
type DelegationAdmissionAudit struct {
Tool string
Verdict string // "allow" | "deny"
Reason string // e.g. "local_fix_no_external_need"
Intent string // taskintent class of the turn
}
// DelegationAdmissionSink is an optional sink capability; implementations
// must keep it content-free, like every other audit channel.
type DelegationAdmissionSink interface {
RecordDelegationAdmission(DelegationAdmissionAudit)
}
// RecordDelegationAdmission forwards a shadow admission verdict only to sinks
// that explicitly opt in. Ordinary UI sinks receive nothing.
func RecordDelegationAdmission(s Sink, a DelegationAdmissionAudit) {
if nilutil.IsNil(s) {
return
}
if da, ok := s.(DelegationAdmissionSink); ok {
da.RecordDelegationAdmission(a)
}
}
// OutcomeProgressSink is an optional sink capability for the shadow outcome
// scorer's per-round samples: counts only, never paths or commands. Shadow
// means observed, not enforced — the novelty guard still decides behavior.
-8
View File
@@ -94,14 +94,6 @@ func (s *syncSink) RecordMemoryRecall(a MemoryRecallAudit) {
}
}
func (s *syncSink) RecordDelegationAdmission(a DelegationAdmissionAudit) {
s.mu.Lock()
defer s.mu.Unlock()
if da, ok := s.inner.(DelegationAdmissionSink); ok {
da.RecordDelegationAdmission(a)
}
}
func (s *syncSink) RecordWorkspaceMutation(m WorkspaceMutation) {
s.mu.Lock()
defer s.mu.Unlock()
+1 -70
View File
@@ -2,7 +2,6 @@ package evidence
import (
"path"
"sort"
"strings"
"reasonix/internal/shellsafe"
@@ -29,23 +28,8 @@ type OutcomeSample struct {
// DebtAge counts consecutive rounds carrying an unverified mutation with
// no discriminating observation; 0 while no verification debt is open.
DebtAge int
// BlindMutations counts mutations since the last discriminating
// observation — the EBM policy's trigger input.
// BlindMutations counts mutations since the last discriminating observation.
BlindMutations int
// EBMEligible/EBMFired mark the Evidence-Before-More-Mutation trigger
// holding and its nudge firing; the agent stamps both so every arm —
// baseline included — carries the eligibility shadow.
EBMEligible bool
EBMFired bool
// LocalExecSeen reports whether this turn has executed any local
// interpreter/test command yet — the self-check-propensity observable
// (studied set: python/node/go run/pytest; ecosystem bias documented).
LocalExecSeen bool
// GovernorEligible/GovernorEngaged mark the reasoning governor's
// exploration trigger holding and its depth override riding requests;
// eligibility is stamped on every arm so baselines carry the shadow.
GovernorEligible bool
GovernorEngaged bool
}
// OutcomeTracker is the shadow counterpart of ProgressTracker: same per-round
@@ -64,42 +48,6 @@ type OutcomeTracker struct {
debt bool
debtAge int
blind int
localExec bool
}
// OutcomeSeed is the fork-portable slice of tracker state: what a
// counterfactual continuation must inherit for its shadow to stay continuous.
type OutcomeSeed struct {
MutatedBases []string `json:"mutated_bases,omitempty"`
DebtAge int `json:"debt_age"`
BlindMutations int `json:"blind_mutations"`
LocalExecSeen bool `json:"local_exec_seen"`
}
// ForkSeed exports the state a counterfactual fork must carry so post-fork
// discriminating detection stays continuous with the original run.
func (t *OutcomeTracker) ForkSeed() OutcomeSeed {
seed := OutcomeSeed{DebtAge: t.debtAge, BlindMutations: t.blind, LocalExecSeen: t.localExec}
for base := range t.mutatedBases {
seed.MutatedBases = append(seed.MutatedBases, base)
}
sort.Strings(seed.MutatedBases)
return seed
}
// RestoreOutcomeTracker rebuilds a tracker from a fork seed. Novelty maps
// start empty — post-fork exploration novelty is intentionally relative to the
// fork point, while debt state continues from the original trajectory.
func RestoreOutcomeTracker(seed OutcomeSeed) *OutcomeTracker {
t := NewOutcomeTracker()
for _, base := range seed.MutatedBases {
t.mutatedBases[base] = true
}
t.debtAge = seed.DebtAge
t.blind = seed.BlindMutations
t.debt = seed.DebtAge > 0 || seed.BlindMutations > 0
t.localExec = seed.LocalExecSeen
return t
}
func NewOutcomeTracker() *OutcomeTracker {
@@ -142,23 +90,9 @@ func (t *OutcomeTracker) ScoreRound(receipts []Receipt) OutcomeSample {
}
s.DebtAge = t.debtAge
s.BlindMutations = t.blind
s.LocalExecSeen = t.localExec
return s
}
// localExecCommand matches the exact command families the affordance study
// validated. Deliberately narrow and Python-ecosystem biased for now;
// generalizing to Local Discriminating Execution needs cross-language
// replication first.
func localExecCommand(command string) bool {
for _, marker := range []string{"python", "node ", "go run", "pytest", "py.test"} {
if strings.Contains(command, marker) {
return true
}
}
return false
}
// noteMutatedPaths remembers mutated file basenames so a later command that
// mentions one (running a repro script, a targeted test file) reads as a
// discriminating observation even when it is not delivery verification.
@@ -227,9 +161,6 @@ func (t *OutcomeTracker) scoreCommand(command string, r Receipt, s *OutcomeSampl
if verify || t.commandExercisesMutation(command) {
s.Discriminating++
}
if localExecCommand(command) {
t.localExec = true
}
if verify {
s.Verification++
seen, wasPass := t.verifySeen[command], t.verifyPass[command]
-5
View File
@@ -216,11 +216,6 @@ func (r *Recorder) RecordMemoryRecall(a event.MemoryRecallAudit) {
event.RecordMemoryRecall(r.inner, a)
}
// RecordDelegationAdmission preserves the wrapped sink's audit capability.
func (r *Recorder) RecordDelegationAdmission(a event.DelegationAdmissionAudit) {
event.RecordDelegationAdmission(r.inner, a)
}
func (r *Recorder) recordUsage(e event.Event) {
r.recordProviderUsage(e.ModelRef, e.Usage, e.CostQuote, e.UsageSource)
}
+46 -14
View File
@@ -1,9 +1,8 @@
// Package taskcontract is the convergence point for the host's task state:
// one Contract assembled purely from signals the runtime already produced —
// the taskintent classification, planner-gate features, plan acceptance
// criteria, and the evidence ledger's receipts. Building or updating a
// contract never makes a model call; every termination arbiter reads the
// same record instead of keeping its own.
// planner-gate features, plan acceptance criteria, and the evidence ledger's
// receipts. Building or updating a contract never makes a model call; every
// termination arbiter reads the same record instead of keeping its own.
package taskcontract
import (
@@ -12,7 +11,6 @@ import (
"strings"
"reasonix/internal/evidence"
"reasonix/internal/taskintent"
)
// Risk is the highest risk any upstream signal assigned to the task.
@@ -125,9 +123,34 @@ type Signals struct {
Paths []string
}
// Kind labels a contract by what its receipts turned out to be, for the
// trajectory record. It is observed after the fact, never predicted from the
// task text.
type Kind uint8
const (
// KindConversation is a turn that produced no host-observable receipt.
KindConversation Kind = iota
// KindObservableRead is a turn whose receipts are reads and commands only.
KindObservableRead
// KindMutation is a turn that successfully changed state.
KindMutation
)
func (k Kind) String() string {
switch k {
case KindObservableRead:
return "observable_read"
case KindMutation:
return "mutation"
default:
return "conversation"
}
}
// Contract is the unified task record.
type Contract struct {
Intent taskintent.Intent
Kind Kind
Risk Risk
Scope Scope
Requirements []Requirement
@@ -136,16 +159,17 @@ type Contract struct {
epoch uint64
}
// New classifies input with the existing taskintent heuristics and returns
// an otherwise empty contract; no model call is made.
func New(input string) *Contract {
return &Contract{Intent: taskintent.Classify(input)}
// New returns an empty contract for one task; requirements arrive from a plan
// or a todo list, and Kind fills in from the receipts Observe folds.
func New(_ string) *Contract {
return &Contract{}
}
// Atomic is the zero-overhead contract for a simple ask: the ask itself is
// the one requirement (auto-satisfied by mutation evidence) and the one
// check is "the workspace changed". No planner, no evaluator, no extra
// round — the host synthesizes it and the ledger completes it.
// Atomic is the zero-overhead contract for a simple ask: the ask itself is the
// one requirement (auto-satisfied by mutation evidence) and the one check is
// "the workspace changed". Whether a turn gets this shape is the caller's call
// and belongs on evidence — a turn that changed something has a change to
// account for, whatever its wording was.
func Atomic(input string) *Contract {
c := New(input)
c.Requirements = append(c.Requirements, Requirement{
@@ -288,6 +312,14 @@ func (c *Contract) AddCheck(command string) {
// current code. Mutation-kind evidence never stales: the change happened;
// whether the fix still holds is verification's job.
func (c *Contract) Observe(r evidence.Receipt) {
if r.Success {
switch {
case r.Mutation || r.Write:
c.Kind = KindMutation
case c.Kind < KindObservableRead:
c.Kind = KindObservableRead
}
}
if r.Mutation || r.Write {
c.epoch++
c.staleOutdated()
+37 -14
View File
@@ -5,15 +5,41 @@ import (
"testing"
"reasonix/internal/evidence"
"reasonix/internal/taskintent"
)
func TestNewClassifiesWithExistingHeuristics(t *testing.T) {
if c := New("fix the bug in utils.py"); c.Intent != taskintent.Mutation {
t.Fatalf("intent = %v, want Mutation", c.Intent)
// atomicFixture is the one-requirement, one-check shape the delivery gates
// exercise: the ask is auto-satisfied by mutation evidence, and the single
// check is "the workspace changed".
func atomicFixture(input string) *Contract {
c := New(input)
c.Requirements = append(c.Requirements, Requirement{
ID: "r1", Text: input, Required: true, Auto: true, AutoKind: EvidenceMutation,
})
c.Checks = append(c.Checks, Check{Kind: CheckMutation})
return c
}
// Kind is read off receipts, never off the task text: the same words produce
// a different label depending on what the turn actually did.
func TestKindFollowsTheReceiptsNotTheText(t *testing.T) {
if c := New("fix the bug in utils.py"); c.Kind != KindConversation {
t.Fatalf("kind = %v, want conversation before any receipt", c.Kind)
}
if c := New("what does this function do?"); c.Intent == taskintent.Mutation {
t.Fatalf("advisory question misclassified as mutation")
c := New("fix the bug in utils.py")
c.Observe(evidence.Receipt{ToolName: "read_file", Read: true, Success: true, Paths: []string{"utils.py"}})
if c.Kind != KindObservableRead {
t.Fatalf("kind = %v, want observable_read after a successful read", c.Kind)
}
c.Observe(evidence.Receipt{ToolName: "edit_file", Mutation: true, Write: true, Success: true, Paths: []string{"utils.py"}})
if c.Kind != KindMutation {
t.Fatalf("kind = %v, want mutation after a successful write", c.Kind)
}
// A failed write proves nothing changed, so the label must not ratchet.
quiet := New("fix the bug in utils.py")
quiet.Observe(evidence.Receipt{ToolName: "edit_file", Mutation: true, Write: true, Success: false})
if quiet.Kind != KindConversation {
t.Fatalf("kind = %v, want conversation when every receipt failed", quiet.Kind)
}
}
@@ -91,10 +117,7 @@ func TestOutstandingListsBlockersOnly(t *testing.T) {
}
func TestAtomicContractCompletesFromOneMutation(t *testing.T) {
c := Atomic("fix typo in README.md")
if c.Intent != taskintent.Mutation {
t.Fatalf("intent = %v, want Mutation", c.Intent)
}
c := atomicFixture("fix typo in README.md")
if !c.Trivial() {
t.Fatal("atomic contract must route trivial (executor-only, no arbiters)")
}
@@ -115,7 +138,7 @@ func TestAtomicContractCompletesFromOneMutation(t *testing.T) {
}
func TestMutationCheckHonorsScopePaths(t *testing.T) {
c := Atomic("fix typo in README.md")
c := atomicFixture("fix typo in README.md")
c.MergeSignals(Signals{Anchored: true, Paths: []string{"README.md"}})
c.Observe(evidence.Receipt{ToolName: "write_file", Mutation: true, Success: true, Paths: []string{"other.txt"}})
if c.Checks[0].Status == Satisfied {
@@ -128,7 +151,7 @@ func TestMutationCheckHonorsScopePaths(t *testing.T) {
}
func TestTrivialRejectsComplexContracts(t *testing.T) {
c := Atomic("fix typo")
c := atomicFixture("fix typo")
c.MergeSignals(Signals{HighRisk: true})
if c.Trivial() {
t.Fatal("high risk is never trivial")
@@ -181,7 +204,7 @@ func TestExecutionViewIsAViewNotAParallelDescription(t *testing.T) {
t.Fatalf("todo %d should render completed after satisfaction: %+v", i, todo)
}
}
atomicView := Atomic("fix typo").ExecutionView()
atomicView := atomicFixture("fix typo").ExecutionView()
if len(atomicView) != 2 || atomicView[1].Content != "apply the change" {
t.Fatalf("atomic view = %+v", atomicView)
}
@@ -229,7 +252,7 @@ func TestMutationStalesVerificationEvidence(t *testing.T) {
}
func TestMutationEvidenceNeverStales(t *testing.T) {
c := Atomic("fix typo in README.md")
c := atomicFixture("fix typo in README.md")
c.Observe(evidence.Receipt{ToolName: "edit_file", Mutation: true, Success: true, Paths: []string{"README.md"}})
if !c.Complete() {
t.Fatal("atomic contract should complete on the mutation")
-93
View File
@@ -1,93 +0,0 @@
package taskintent
import (
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"strings"
"testing"
)
// allowedExports is the package's whole public surface. Adding a name here
// means taskintent is absorbing a new responsibility — read doc.go first:
// complexity, risk, depth, completion, budget and tool-surface decisions
// belong to runtime evidence, not keywords.
var allowedExports = map[string]bool{
"Intent": true, "Conversation": true, "Advisory": true,
"ObservableRead": true, "Mutation": true, "PersistentAction": true,
"Classify": true, "NeedsEvidence": true, "NeedsMutation": true,
"NeedsPersistentAction": true, "GoalNeedsWriteBudget": true,
"BudgetClassSimple": true, "BudgetClassWrite": true, "BudgetClassResearch": true,
"ClassifyGoalBudget": true,
}
// lineBudgets caps the heuristic files: vocabulary growth must displace
// something or justify a deliberate budget bump in review.
var lineBudgets = map[string]int{
"intent.go": 620,
"heuristic.go": 180,
"goal_budget.go": 140,
"goal_research_budget.go": 120,
}
func TestExportSurfaceIsFrozen(t *testing.T) {
fset := token.NewFileSet()
entries, err := os.ReadDir(".")
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
name := entry.Name()
if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
file, err := parser.ParseFile(fset, name, nil, 0)
if err != nil {
t.Fatal(err)
}
{
for _, decl := range file.Decls {
switch d := decl.(type) {
case *ast.FuncDecl:
checkExport(t, d.Name.Name)
case *ast.GenDecl:
for _, spec := range d.Specs {
switch s := spec.(type) {
case *ast.TypeSpec:
checkExport(t, s.Name.Name)
case *ast.ValueSpec:
for _, name := range s.Names {
checkExport(t, name.Name)
}
}
}
}
}
}
}
}
func checkExport(t *testing.T, name string) {
t.Helper()
if !ast.IsExported(name) {
return
}
if !allowedExports[name] {
t.Errorf("new exported name %q: taskintent's surface is frozen (doc.go); classification facts belong in taskcontract signals, not here", name)
}
}
func TestHeuristicFilesStayWithinBudget(t *testing.T) {
for name, budget := range lineBudgets {
data, err := os.ReadFile(filepath.Clean(name))
if err != nil {
t.Fatalf("%s: %v", name, err)
}
lines := strings.Count(string(data), "\n")
if lines > budget {
t.Errorf("%s is %d lines, budget %d: shrink the vocabulary or justify a deliberate budget bump — do not accrete keywords (doc.go)", name, lines, budget)
}
}
}
@@ -1,80 +0,0 @@
package taskintent
import "testing"
// TestDeliveryClassificationMatrix protects the boundary between advisory
// troubleshooting, observable read-only work, and mutation delivery.
func TestDeliveryClassificationMatrix(t *testing.T) {
tests := []struct {
name string
input string
wantEvidence bool
wantMutation bool
}{
{"make sense", "why does this make sense?", false, false},
{"node prose", "why does the node selection matter?", false, false},
{"swift prose", "why does Swift concurrency work this way?", false, false},
{"go prose", "why does this go wrong so often?", false, false},
{"remote url", "why can't I open https://github.com?", false, false},
{"remote analysis", "can you analyze why Outlook won't sync?", false, false},
{"how to install", "how do I install the plugin?", false, false},
{"chinese how to install", "如何安装插件", false, false},
{"chinese how to modify", "怎么修改 WPS 设置", false, false},
{"python prose", "why is Python popular?", false, false},
{"docker prose", "why is Docker popular?", false, false},
{"pytest prose", "why is pytest popular?", false, false},
{"backtick python prose", "why is `Python` popular?", false, false},
{"backtick code identifier", "what does `context.Context` mean?", false, false},
{"double dash prose", "why is this -- strangely -- happening?", false, false},
{"advisory upgrade", "为什么升级插件后失败", false, false},
{"advisory adjustment", "为什么调整设置后没有效果", false, false},
{"advisory change application", "为什么申请修改失败", false, false},
{"negated change", "please don't make the requested changes", false, false},
{"negated chinese", "请勿修改代码", false, false},
{"negated inspection", "Explain why it fails. Do not inspect or change files.", false, false},
{"negated chinese inspection", "解释失败原因,不要检查或修改文件", false, false},
{"review only", "review only and do not fix anything", true, false},
{"markdown anchor", "why does README.md render incorrectly?", true, false},
{"git command", "why does git diff --check fail?", true, false},
{"custom command", "why does mytool --strict fail?", true, false},
{"repository anchor", "can you analyze this repository and explain why it fails?", true, false},
{"observable audit", "audit the current configuration", true, false},
{"make changes", "make the necessary changes", true, true},
{"thanks then update", "thanks for fixing that, now update the tests", true, true},
{"chinese thanks then update", "谢谢你,请继续修改配置", true, true},
{"thanks then review", "thanks for the help; now review this pull request", true, false},
{"modify", "please modify the config", true, true},
{"push", "push the branch", true, true},
{"commit", "commit the fix", true, true},
{"move", "move the file", true, true},
{"bump", "bump the dependency", true, true},
{"advice then fix", "why does it fail and fix it", true, true},
{"polite fix before advice", "could you please fix why it fails", true, true},
{"chinese polite fix before advice", "请你帮我修复为什么会失败", true, true},
{"chinese advice then fix", "为什么失败然后修复它", true, true},
{"negated install then patch", "I cannot install dependencies but patch the parser", true, true},
{"shared negation", "I cannot install and update dependencies", false, false},
{"reset negation", "I cannot install dependencies and please update config", true, true},
{"chinese reset", "无法安装依赖请在现有配置中修改", true, true},
{"negated request", "不想请团队修改代码", false, false},
{"negated application", "禁止申请修改配置", false, false},
{"deferred conversation token", "Remember ORBIT-42 and answer on the next turn.", false, false},
{"chinese deferred conversation token", "记住 ORBIT-42,下一轮回答", false, false},
{"deferred token with durable negation", "Remember ORBIT-42 for the next turn. Do not save it permanently.", false, false},
{"chinese deferred token with durable negation", "记住 ORBIT-42,下一轮回答。不要写入文件或长期记忆。", false, false},
{"long conversational context", "Please keep this code in mind because I will ask you about it in my next message", false, false},
{"durable memory", "Remember ORBIT-42 permanently across sessions", true, true},
{"chinese durable memory", "请永久记住 ORBIT-42,跨会话也要保留", true, true},
{"durable memory advice", "How do I save a preference permanently across sessions?", false, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := NeedsEvidence(tt.input); got != tt.wantEvidence {
t.Errorf("NeedsEvidence(%q) = %v, want %v", tt.input, got, tt.wantEvidence)
}
if got := NeedsMutation(tt.input); got != tt.wantMutation {
t.Errorf("NeedsMutation(%q) = %v, want %v", tt.input, got, tt.wantMutation)
}
})
}
}
-14
View File
@@ -1,14 +0,0 @@
// Package taskintent answers classification questions from task text: is this
// obviously chat, a read, a mutation, or a persistent action, and which Goal
// turn-budget class (simple/write/research) the objective should start on.
// It must not grow into complexity, risk, planner depth, verification depth,
// completion, or tool-surface decisions — those belong to runtime evidence
// (see internal/taskcontract), where a receipt outranks any keyword.
//
// The vocabulary is a liability, not an asset: every added keyword, negation
// rule, or language case moves this package toward an unowned NLP parser.
// boundary_test.go enforces the line: the exported surface is a whitelist
// and the heuristic files carry a hard size budget, so growth is a reviewed
// decision, never an accretion. When a classification is wrong, prefer
// fixing it downstream with evidence over teaching this package more words.
package taskintent
-125
View File
@@ -1,125 +0,0 @@
package taskintent
import (
"strings"
)
// taskFaultSignals are CN/EN failure/fault phrases shared by task recognition
// and Goal budget classification so the two surfaces cannot drift apart.
var taskFaultSignals = []string{
"not working", "isn't working", "doesn't work", "dont work", "don't work",
"broken", "error", "bug", "issue", "failed", "failing", "crash", "exception", "panic", "segfault",
"问题", "不工作", "报错", "错误", "失败", "崩溃", "异常",
"卡住", "卡住了", "没反应", "不生效", "异常退出", "历史 bug", "历史bug",
}
// taskFaultReadonlySignals mark diagnosis/analysis-only work. A bare fault
// statement without these (and without advisory/no-modify constraints) is
// treated as a Goal write budget.
var taskFaultReadonlySignals = []string{
"diagnose", "diagnosis", "analyze", "analyse", "inspect", "review", "reproduce",
"identify the root cause", "root cause", "investigate", "audit", "verify", "check why",
"诊断", "排查", "定位", "分析", "检查", "复现", "评审", "审查", "审计", "验证",
"原因", "根因",
}
// GoalNeedsWriteBudget reports whether a Goal objective should start on the
// extended write turn budget. It preserves every explicit mutation classification used by
// ordinary Delivery, then additionally treats bare fault/bug/crash statements as
// write work unless the user asked only for analysis, explanation, or a no-modify
// constraint. Ordinary Delivery evidence/mutation gates keep using
// NeedsMutation / Classify unchanged.
func GoalNeedsWriteBudget(input string) bool {
if NeedsMutation(input) {
return true
}
return goalBareFaultImpliesWrite(input)
}
func goalBareFaultImpliesWrite(input string) bool {
normalized := strings.ToLower(strings.TrimSpace(input))
if normalized == "" || !taskInputHasFaultSignal(normalized) {
return false
}
// Explicit "do not modify/fix" (or equivalent) keeps the goal on simple.
if goalHasNoModifyConstraint(normalized) {
return false
}
// Questions and explanation intent stay consultative.
if deliveryTaskIsAdvisory(normalized) || goalHasQuestionIntent(normalized) {
return false
}
// Explicit read-only diagnostic wording stays on the simple budget even when
// it mentions a bug/crash/failure.
if goalHasReadonlyDiagnosticIntent(normalized) {
return false
}
return true
}
func taskInputHasFaultSignal(input string) bool {
normalized := strings.ToLower(strings.TrimSpace(input))
for _, phrase := range taskFaultSignals {
if strings.Contains(normalized, phrase) {
return true
}
}
return false
}
func goalHasQuestionIntent(input string) bool {
if strings.ContainsAny(input, "??") {
return true
}
// Explanation/consultative stems beyond the shared advisory phrase list.
for _, phrase := range []string{
"explain", "what happened", "what's happening", "what is happening",
"tell me about", "look into why",
"解释", "说明一下", "怎么回事", "是什么情况", "什么情况",
} {
if strings.Contains(input, phrase) {
return true
}
}
return false
}
func goalHasReadonlyDiagnosticIntent(input string) bool {
for _, phrase := range taskFaultReadonlySignals {
if strings.Contains(input, phrase) {
return true
}
}
return deliveryTaskIsAdvisory(input)
}
func goalHasNoModifyConstraint(input string) bool {
if _, negated := deliveryTaskMutationIntent(input); negated && !deliveryTaskHasMutationIntent(input) {
return true
}
for _, phrase := range []string{
"do not fix", "don't fix", "dont fix", "do not change", "don't change", "dont change",
"do not modify", "don't modify", "dont modify", "do not edit", "don't edit",
"without fixing", "without changing", "without modifying", "analysis only", "review only",
"不要修复", "不要修改", "不要改动", "不要改", "别修复", "别修改", "勿修改",
"只分析", "仅分析", "只检查", "仅检查", "只诊断", "仅诊断", "只定位", "仅定位",
"只排查", "仅排查", "不要修",
} {
if strings.Contains(input, phrase) {
return true
}
}
// Trailing constraints after punctuation, e.g. "复现并定位问题,但不要修复。"
for _, clause := range deliveryTaskClauses(input) {
clause = strings.TrimSpace(clause)
if clause == "" {
continue
}
for _, phrase := range []string{"不要修复", "不要修改", "do not fix", "don't fix", "dont fix"} {
if strings.Contains(clause, phrase) {
return true
}
}
}
return false
}
-114
View File
@@ -1,114 +0,0 @@
package taskintent
import "testing"
func TestGoalNeedsWriteBudgetMatrix(t *testing.T) {
// User-reported Chinese bare fault statement must land on the write budget.
const userReported = "数据模型管理器又出现历史 BUG 了……"
if !GoalNeedsWriteBudget(userReported) {
t.Fatalf("GoalNeedsWriteBudget(%q) = false, want write", userReported)
}
// Ordinary Delivery must still treat the same bare fault as non-mutation
// (observable read / evidence, not write-required).
if NeedsMutation(userReported) {
t.Fatalf("NeedsMutation(%q) changed; ordinary Delivery must stay non-mutation", userReported)
}
writeCases := []string{
userReported,
"应用打开设置时崩溃",
"the auth service crashes on login",
"parser throws an exception on empty input",
"fix the crash in a.go",
"帮我修复wps的崩溃问题",
"why does it fail and fix it",
"为什么失败然后修复它",
"解释失败原因并修复",
}
for _, input := range writeCases {
if !GoalNeedsWriteBudget(input) {
t.Errorf("Goal write budget missing for %q", input)
}
}
simpleCases := []string{
"为什么会出现这个 BUG?",
"只分析原因,不要修改代码。",
"诊断数据库连接失败原因。",
"复现并定位问题,但不要修复。",
"why does this bug happen?",
"explain the crash without changing code",
"reproduce the crash and identify the root cause",
"review only and do not fix anything",
"hello",
}
for _, input := range simpleCases {
if GoalNeedsWriteBudget(input) {
t.Errorf("Goal simple budget expected for %q", input)
}
}
}
func TestGoalBareFaultDoesNotChangeDeliveryClassification(t *testing.T) {
// Pin ordinary Delivery consultation/diagnosis so Goal write inference
// cannot drift the shared delivery gates.
readonly := []string{
"为什么会出现这个 BUG?",
"诊断数据库连接失败原因。",
"reproduce the crash and identify the root cause",
"应用打开设置时崩溃",
"数据模型管理器又出现历史 BUG 了……",
}
for _, input := range readonly {
if NeedsMutation(input) {
t.Errorf("delivery mutation incorrectly true for %q", input)
}
}
mutation := []string{
"fix the crash in a.go",
"为什么失败然后修复它",
}
for _, input := range mutation {
if !NeedsMutation(input) {
t.Errorf("delivery mutation incorrectly false for %q", input)
}
}
}
func TestClassifyGoalBudgetMatrix(t *testing.T) {
if got := ClassifyGoalBudget("hello"); got != BudgetClassSimple {
t.Fatalf("simple = %q", got)
}
if got := ClassifyGoalBudget("fix the crash in a.go"); got != BudgetClassWrite {
t.Fatalf("write = %q", got)
}
if got := ClassifyGoalBudget("持续排查这个线上卡顿直到根因明确,并验证修复"); got != BudgetClassResearch {
t.Fatalf("research = %q", got)
}
}
func TestTaskFaultSignalsSharedWithGoalClassification(t *testing.T) {
// Shared fault list must keep task recognition and Goal classification
// aligned for bare problem statements.
for _, phrase := range []string{"bug", "crash", "崩溃", "异常", "报错", "失败"} {
input := "something " + phrase + " happened"
if !taskInputHasFaultSignal(input) {
t.Errorf("taskInputHasFaultSignal missing %q", phrase)
}
if !heuristicInputIsTask(input) && !stringsContainsCJK(phrase) {
// English bare fault remains a task signal.
if !heuristicInputIsTask("the service has a " + phrase) {
t.Errorf("heuristic task signal missing for fault %q", phrase)
}
}
}
}
func stringsContainsCJK(s string) bool {
for _, r := range s {
if r > 127 {
return true
}
}
return false
}
@@ -1,77 +0,0 @@
package taskintent
import "strings"
// Legacy Goal classes remain for sidecar and deprecated CLI compatibility.
const (
BudgetClassSimple = "simple"
BudgetClassWrite = "write"
BudgetClassResearch = "research"
)
// ClassifyGoalBudget selects simple/write/research from goal text alone.
// Legacy CLI flags and sidecars apply on/off overrides in the control package.
func ClassifyGoalBudget(goal string) string {
if needsResearchBudget(goal) {
return BudgetClassResearch
}
if GoalNeedsWriteBudget(goal) {
return BudgetClassWrite
}
return BudgetClassSimple
}
func needsResearchBudget(goal string) bool {
trimmed := strings.TrimSpace(goal)
if trimmed == "" {
return false
}
lower := strings.ToLower(trimmed)
if strings.Contains(lower, ".reasonix/autoresearch/") {
return true
}
for _, kw := range researchBudgetStrongKeywords {
if strings.Contains(lower, kw) {
return true
}
}
return researchBudgetPhaseCount(lower) >= 4
}
func researchBudgetPhaseCount(lower string) int {
categories := 0
for _, group := range researchBudgetPhaseKeywords {
if containsAnyGoalKeyword(lower, group) {
categories++
}
}
return categories
}
func containsAnyGoalKeyword(s string, needles []string) bool {
for _, needle := range needles {
if strings.Contains(s, needle) {
return true
}
}
return false
}
var researchBudgetStrongKeywords = []string{
"持续", "长期", "彻底", "直到根因", "根因明确", "多轮",
"不要原地打转", "别原地打转", "完整方案", "完整做成方案",
"跑实验", "反复验证", "长期优化", "系统性研究", "持续研究",
"持续排查", "持续推进", "长期跑",
"long-horizon", "long horizon", "long-running", "keep researching",
"keep working", "root cause", "until the root cause", "do not spin",
"don't spin", "thoroughly", "systematically",
}
var researchBudgetPhaseKeywords = [][]string{
{"研究", "调研", "排查", "分析", "定位", "诊断", "research", "investigate", "diagnose", "analyze", "analysis"},
{"实现", "修复", "改造", "开发", "重构", "implement", "build", "fix", "refactor"},
{"验证", "测试", "复现", "联调", "benchmark", "verify", "validate", "test", "reproduce"},
{"优化", "完善", "提升", "收敛", "optimize", "improve", "tune", "polish"},
{"文档", "方案", "说明", "总结", "document", "docs", "writeup", "plan"},
{"发布", "上线", "提交", "pull request", "publish", "ship", "deploy"},
}
-169
View File
@@ -1,169 +0,0 @@
package taskintent
import (
"slices"
"strings"
"unicode/utf8"
)
// heuristicInputIsTask reports whether a user input reads as an actionable
// task rather than conversational chat. The delivery evidence gate uses it to
// decide when a turn should be held to acceptance-criteria expectations
// (NeedsEvidence); greetings and acknowledgements must not arm the
// delivery gates.
func heuristicInputIsTask(input string) bool {
trimmed := strings.TrimSpace(input)
if trimmed == "" {
return false
}
normalized := strings.ToLower(strings.Trim(trimmed, " \t\r\n.!?。!?,,;;::"))
// Very short greeting/acknowledgement whitelist (1-3 words).
shortGreetings := []string{
"hello", "hi", "hey", "你好", "您好", "nihao",
"thanks", "thank you", "谢谢", "谢了",
"ok", "okay", "好的", "嗯", "行",
"got it", "i see", "明白", "了解", "收到", "我知道了", "先不用",
}
words := strings.Fields(normalized)
if len(words) <= 3 {
if slices.Contains(shortGreetings, normalized) {
return false
}
}
// Polite acknowledgements can contain action words from the completed
// task ("thanks for fixing") but should stay conversational.
chatPhrases := []string{
"thanks for", "thank you for", "i'll check later", "i will check later",
"i'll test it later", "i will test it later", "that test was helpful", "the test was helpful",
"谢谢你", "辛苦了",
}
for _, phrase := range chatPhrases {
before, after, ok := strings.Cut(normalized, phrase)
if !ok {
continue
}
// Acknowledgement wording only short-circuits a purely conversational
// turn. Preserve a real task before it or after an explicit transition,
// e.g. "thanks for fixing that; now update the tests".
if prefix := strings.TrimSpace(before); prefix != "" && heuristicInputHasStrongTaskSignal(prefix) {
return true
}
if deliveryTaskHasFollowUpAfterChat(after) {
return true
}
return false
}
// Ambiguous prose stays conversational. Delivery evidence gates require a
// concrete host-observable signal rather than using message length as a
// proxy; explicit mutations, files, commands, failures, and audit verbs are
// still classified below.
return heuristicInputHasStrongTaskSignal(normalized)
}
func heuristicInputHasStrongTaskSignal(input string) bool {
normalized := strings.ToLower(strings.TrimSpace(input))
// File references and concrete commands are strong task signals. Shared
// parsing keeps email addresses and remote product names from accidentally
// arming the delivery gate while covering ordinary repository file types.
if deliveryTaskHasFileReference(normalized) || deliveryTaskHasCommand(normalized) {
return true
}
// Mutation intent has a richer, negation-aware vocabulary than this generic
// task heuristic. Reuse it so short requests such as "push the branch" do not
// bypass delivery gates merely because the two keyword lists drift apart.
if deliveryTaskHasMutationIntent(normalized) || NeedsPersistentAction(normalized) {
return true
}
// Failure/help descriptions are actionable even when phrased without an
// imperative verb, e.g. "the auth isn't working". Shared fault signals keep
// task recognition and Goal budget classification from drifting apart.
if taskInputHasFaultSignal(normalized) {
return true
}
helpPhrases := []string{
"can you help", "help with", "cannot", "can't",
"无法", "不能",
}
for _, phrase := range helpPhrases {
if strings.Contains(normalized, phrase) {
return true
}
}
// Action keyword detection.
actionNeedles := []string{
"fix", "debug", "repair", "resolve", "reproduce",
"create", "add", "write", "edit", "update", "change", "delete", "remove", "rename",
"review", "inspect", "analyze", "check", "audit", "verify", "test", "run", "build", "implement", "refactor", "modify", "patch", "replace",
"configure", "upgrade", "downgrade", "enable", "disable", "merge", "make changes", "make a change", "make the changes",
"make the requested changes", "make the necessary changes", "make these changes", "make those changes", "make code changes",
"continue work", "continue the", "continue this",
"修复", "调试", "解决", "复现", "创建", "新建", "添加", "编写", "编辑", "修改", "更新",
"删除", "移除", "重命名", "评审", "检查", "分析", "审计", "验证", "测试", "运行", "构建", "实现", "重构", "继续处理",
"调整", "替换", "移动", "升级", "降级", "启用", "禁用", "合并", "改动", "打补丁",
"看看", "看下", "帮我看", "帮我看下", "处理下", "处理一下", "排查", "定位",
}
for _, needle := range actionNeedles {
if containsTaskNeedle(normalized, needle) {
return true
}
}
return false
}
func deliveryTaskHasFollowUpAfterChat(input string) bool {
for index, current := range input {
switch current {
case '.', ',', ';', '!', '?', '。', ',', ';', '!', '?':
candidate := strings.TrimSpace(input[index+utf8.RuneLen(current):])
if candidate != "" && heuristicInputHasStrongTaskSignal(candidate) {
return true
}
}
}
for _, cue := range []string{
" but ", " however ", " nevertheless ", " now ", " then ", " and ", " please ", " so ", " therefore ",
"但是", "但请", "不过", "现在", "然后", "所以", "请", "继续", "再",
} {
for rest := input; ; {
index := strings.Index(rest, cue)
if index < 0 {
break
}
candidate := strings.TrimSpace(rest[index+len(cue):])
if candidate != "" && heuristicInputHasStrongTaskSignal(candidate) {
return true
}
rest = rest[index+len(cue):]
}
}
return false
}
func containsTaskNeedle(input, needle string) bool {
if needle == "" {
return false
}
if containsNonASCII(needle) || strings.Contains(needle, " ") {
return strings.Contains(input, needle)
}
return slices.Contains(strings.FieldsFunc(input, func(r rune) bool {
return !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') && r != '_'
}), needle)
}
func containsNonASCII(s string) bool {
for _, r := range s {
if r > 127 {
return true
}
}
return false
}
-84
View File
@@ -1,84 +0,0 @@
package taskintent
import "testing"
// TestHeuristicInputIsTask covers the delivery evidence gate's task-vs-chat
// heuristic: greetings and acknowledgements must not arm the delivery gates,
// while actionable requests and failure descriptions must.
func TestHeuristicInputIsTask(t *testing.T) {
tests := []struct {
name string
input string
want bool
}{
// Greetings / acknowledgements — chat.
{"hello", "hello", false},
{"hi", "hi", false},
{"你好", "你好", false},
{"thanks", "thanks", false},
{"谢谢", "谢谢", false},
{"ok", "ok", false},
{"好的", "好的", false},
{"收到", "收到", false},
{"我知道了", "我知道了", false},
{"先不用", "先不用", false},
// Explicit tasks.
{"fix bug", "fix the bug", true},
{"create component", "create a component", true},
{"修复问题", "修复这个问题", true},
{"run tests", "run tests", true},
{"modify config", "modify config", true},
{"make changes", "make the requested changes", true},
{"adjust config", "调整现有配置", true},
{"push branch", "push the branch", true},
{"publish release", "publish release", true},
{"看看", "帮我看看这个错误", true},
{"帮我看下", "帮我看下这个问题", true},
{"处理下", "处理下这个 issue", true},
{"排查", "排查一下启动失败", true},
{"定位", "定位这个异常", true},
// Conversational acknowledgements that contain task words.
{"thanks for fixing", "thanks for fixing that!", false},
{"check later", "I'll check later", false},
{"test later", "I'll test it later", false},
{"test was helpful", "that test was helpful", false},
{"辛苦了", "辛苦了", false},
{"thanks then update", "thanks for fixing that, now update the tests", true},
{"chinese thanks then update", "谢谢你,请继续修改配置", true},
{"task before thanks", "review this PR; thanks for the help", true},
// Actionable problem descriptions without imperative verbs.
{"auth not working", "the auth isn't working", true},
{"help with login", "can you help with login?", true},
{"问题严重", "这个问题很严重", true},
{"卡住了", "页面卡住了", true},
{"没反应", "按钮点击没反应", true},
{"不生效", "配置不生效", true},
{"异常退出", "程序异常退出", true},
// File references.
{"file reference", "what about @auth.go", true},
{"localized file reference", "检查(@配置.yaml)", true},
{"python file", "check main.py", true},
{"markdown file", "why does README.md render incorrectly", true},
{"relative script", "why does ./scripts/verify.sh fail", true},
{"email is not file reference", "thanks, email [email protected] later", false},
{"long conversation is not task", "Remember ORBIT-42 and answer on the next turn please", false},
{"durable memory is task", "Remember ORBIT-42 permanently across sessions", true},
// Edge cases.
{"empty", "", false},
{"spaces", " ", false},
{"question mark", "?", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := heuristicInputIsTask(tt.input); got != tt.want {
t.Errorf("heuristicInputIsTask(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
-604
View File
@@ -1,604 +0,0 @@
// Package taskintent classifies natural-language task text (English and
// Chinese) into delivery-intent categories. The delivery evidence gates and
// Goal budget selection consume it as a heuristic; it never gates permissions
// or whether writes are allowed.
package taskintent
import (
"strings"
"unicode/utf8"
)
// Intent is the delivery expectation a task text implies.
type Intent uint8
const (
// Conversation is chat with no host-observable work expected.
Conversation Intent = iota
// Advisory asks for explanation or advice rather than observable work.
Advisory
// ObservableRead expects host-observable read-only work (inspect, review).
ObservableRead
// Mutation expects workspace changes.
Mutation
// PersistentAction expects durable state kept across sessions.
PersistentAction
)
// Classify maps a task text to the delivery intent it implies.
func Classify(input string) Intent {
switch {
case deliveryTaskHasMutationIntent(input):
return Mutation
case NeedsPersistentAction(input):
return PersistentAction
case deliveryTaskIsConversationOnly(input):
return Conversation
case !heuristicInputIsTask(input):
return Conversation
case deliveryTaskIsAdvisory(input):
return Advisory
default:
return ObservableRead
}
}
// NeedsEvidence reports whether the intent expects host-observable work.
func (i Intent) NeedsEvidence() bool {
return i == ObservableRead || i == Mutation || i == PersistentAction
}
// NeedsEvidence reports whether a task text expects host-observable work.
func NeedsEvidence(input string) bool {
return Classify(input).NeedsEvidence()
}
var deliveryMutationNeedles = []string{
"fix", "repair", "resolve", "create", "add", "write", "edit", "update", "change", "delete", "remove", "rename",
"implement", "refactor", "apply", "install", "publish", "commit", "push", "continue work",
"modify", "patch", "replace", "move", "configure", "upgrade", "downgrade", "bump", "enable", "disable", "merge",
"make changes", "make a change", "make the changes", "make the requested changes", "make the necessary changes", "make these changes", "make those changes", "make code changes",
"修复", "解决", "创建", "新建", "添加", "编写", "编辑", "修改", "更新", "删除", "移除", "重命名", "实现", "重构",
"实施", "落地", "安装", "发布", "提交", "继续处理", "调整", "替换", "移动", "升级", "降级", "启用", "禁用", "合并", "改动", "打补丁",
}
var deliveryAdvisoryPhrases = []string{
"what's wrong", "what is wrong", "why", "what should i do", "what can i do", "how should i", "how do i", "how can i",
"can you explain", "could you explain", "give me advice", "any advice", "help me understand",
"为什么", "怎么回事", "怎么办", "怎么", "怎样", "如何", "是什么问题", "什么原因", "的原因", "给我建议", "有什么建议",
}
func NeedsMutation(input string) bool {
intent := Classify(input)
return intent == Mutation || intent == PersistentAction
}
func deliveryTaskHasMutationIntent(input string) bool {
affirmative, _ := deliveryTaskMutationIntent(input)
return affirmative
}
func NeedsPersistentAction(input string) bool {
normalized := strings.ToLower(strings.TrimSpace(input))
if normalized == "" {
return false
}
actionNeedles := []string{
"remember", "save", "store", "keep this", "keep that",
"记住", "记下来", "保存", "存下来", "记录下来",
}
durableNeedles := []string{
"permanently", "durable", "long-term", "long term", "across sessions", "future sessions", "every session", "after restart", "after restarting",
"永久", "长期", "持久", "跨会话", "以后每次", "未来会话", "重启后", "下次启动",
}
for _, clause := range deliveryTaskClauses(normalized) {
action := false
for _, needle := range actionNeedles {
affirmative, _ := deliveryTaskNeedleIntent(clause, needle)
action = action || affirmative
}
durable := false
for _, needle := range durableNeedles {
affirmative, _ := deliveryTaskNeedleIntent(clause, needle)
durable = durable || affirmative
}
if action && durable && !deliveryTaskClauseIsAdvisory(clause) {
return true
}
}
return false
}
func deliveryTaskIsConversationOnly(input string) bool {
normalized := strings.ToLower(strings.TrimSpace(input))
if normalized == "" || deliveryTaskHasHostAnchor(normalized) || deliveryTaskHasCommand(normalized) {
return false
}
localCue := containsAnySubstring(normalized, []string{
"next turn", "next message", "later in this chat", "this conversation", "when i ask again", "when i ask next",
"下一轮", "下轮", "下一条消息", "稍后再问", "待会再问", "这个对话", "本次对话", "本轮会话",
})
conversationAction := containsAnySubstring(normalized, []string{
"remember", "keep in mind", "keep this", "keep that", "answer", "respond", "reply",
"记住", "记一下", "回答", "回复", "再告诉我",
})
return localCue && conversationAction
}
func deliveryTaskMutationIntent(input string) (affirmative, negated bool) {
normalized := strings.ToLower(strings.TrimSpace(input))
for _, clause := range deliveryTaskClauses(normalized) {
clauseAffirmative := false
clauseNegated := false
if deliveryMutationClauseNegated(clause) {
clauseNegated = true
}
for _, needle := range deliveryMutationNeedles {
hasAffirmative, hasNegated := deliveryTaskNeedleIntent(clause, needle)
clauseAffirmative = clauseAffirmative || hasAffirmative
clauseNegated = clauseNegated || hasNegated
}
if clauseAffirmative && deliveryTaskClauseIsAdvisory(clause) && !deliveryTaskAdvisoryClauseRequestsMutation(clause) {
clauseAffirmative = false
clauseNegated = true
}
affirmative = affirmative || clauseAffirmative
negated = negated || clauseNegated
}
return affirmative, negated
}
func deliveryTaskIsAdvisory(input string) bool {
normalized := strings.ToLower(strings.TrimSpace(input))
// Concrete targets and commands always remain host-observable, including
// when the request is phrased as a "why" question.
if deliveryTaskHasHostAnchor(normalized) || deliveryTaskHasCommand(normalized) {
return false
}
// Question wording is scoped per clause. This keeps remote troubleshooting
// such as "analyze why WPS won't open" advisory, while a separate imperative
// clause such as "reproduce the crash" still requires observable work.
sawAdvisory := false
for _, clause := range deliveryTaskClauses(normalized) {
if deliveryTaskClauseIsAdvisory(clause) {
sawAdvisory = true
continue
}
if deliveryTaskClauseHasObservableWork(clause) {
return false
}
}
if sawAdvisory {
return true
}
// A standalone refusal, inability, or constraint around a mutation verb is
// advisory rather than work Reasonix can perform. Affirmative mixed intent is
// handled by NeedsMutation before this function is consulted.
_, negatedMutation := deliveryTaskMutationIntent(normalized)
return negatedMutation
}
func deliveryTaskHasHostAnchor(input string) bool {
for _, anchor := range []string{
"this repo", "this repository", "current repository", "codebase", "workspace", "pull request", "this pr", "ci job",
"/pull/", "actions/runs/",
"当前仓库", "这个仓库", "当前项目", "这个项目", "代码库", "工作区", "这个 pr", "这个pr", "此 pr", "此pr",
} {
if strings.Contains(input, anchor) {
return true
}
}
return deliveryTaskHasFileReference(input)
}
func deliveryTaskHasFileReference(input string) bool {
previous := rune(0)
for index, current := range input {
if current == '@' && index+1 < len(input) &&
(index == 0 || strings.ContainsRune(" \t\r\n([{<,:;(【《,。;:", previous)) {
next, _ := utf8.DecodeRuneInString(input[index+1:])
if !strings.ContainsRune(" \t\r\n", next) {
return true
}
}
previous = current
}
for _, raw := range strings.FieldsFunc(input, func(r rune) bool {
switch r {
case ' ', '\t', '\r', '\n', '`', '\'', '"', '(', ')', '[', ']', '{', '}', '<', '>', ',', ',', ';', ';', '!', '!', '?', '?':
return true
default:
return false
}
}) {
token := strings.ToLower(strings.TrimSpace(raw))
if token == "" || strings.Contains(token, "://") {
continue
}
if strings.HasPrefix(token, "./") || strings.HasPrefix(token, "../") ||
strings.HasPrefix(token, "/") || strings.Contains(token, `\`) {
return true
}
base := token
if slash := strings.LastIndexByte(base, '/'); slash >= 0 {
base = base[slash+1:]
}
switch base {
case "dockerfile", "makefile", "cmakelists.txt", "justfile", "license", "readme", "changelog":
return true
}
dot := strings.LastIndexByte(base, '.')
if dot < 0 {
continue
}
switch base[dot:] {
case ".go", ".mod", ".sum", ".js", ".jsx", ".ts", ".tsx", ".py", ".rs", ".java", ".kt", ".swift",
".c", ".cc", ".cpp", ".h", ".hpp", ".cs", ".rb", ".php", ".sh", ".zsh", ".fish", ".ps1",
".md", ".json", ".yaml", ".yml", ".toml", ".xml", ".sql", ".proto", ".html", ".css", ".scss",
".vue", ".svelte", ".txt", ".log", ".csv", ".pdf", ".env", ".ini", ".conf", ".lock":
return true
}
}
return false
}
func deliveryTaskHasCommand(input string) bool {
tokens := strings.FieldsFunc(strings.ToLower(input), func(r rune) bool {
asciiWord := r >= 'a' && r <= 'z' || r >= '0' && r <= '9'
return !asciiWord && r != '_' && r != '-' && r != '.' && r != '/' && r != '\\' && r != ':'
})
for i := range tokens {
if deliveryCommandStartsAt(tokens, i) {
return true
}
}
return false
}
func deliveryCommandStartsAt(tokens []string, index int) bool {
command := strings.TrimSpace(tokens[index])
if command == "" {
return false
}
if strings.HasPrefix(command, "./") || strings.HasPrefix(command, "../") ||
strings.HasPrefix(command, "/") || strings.Contains(command, `\`) {
return true
}
next := ""
if index+1 < len(tokens) {
next = tokens[index+1]
}
if next != "--" && len(next) > 1 && strings.HasPrefix(next, "-") {
return true
}
previous := ""
if index > 0 {
previous = tokens[index-1]
}
switch command {
case "go":
switch next {
case "build", "clean", "doc", "env", "fmt", "generate", "get", "install", "list", "mod", "run", "test", "tool", "version", "vet", "work":
return true
}
case "git", "npm", "npx", "pnpm", "yarn", "bun", "deno", "cargo", "rustc", "python", "python3",
"bash", "sh", "zsh", "fish", "powershell", "pwsh", "docker", "docker-compose", "kubectl", "helm", "terraform",
"gradle", "gradlew", "mvn", "dotnet", "xcodebuild", "gcc", "g++", "clang", "clang++":
return deliveryCommandHasExplicitCue(previous) || deliveryCommandHasSubcommand(next)
case "node":
return deliveryCommandHasExplicitCue(previous) || next == "inspect" || next == "test"
case "swift":
return next == "build" || next == "package" || next == "run" || next == "test"
case "make", "just":
switch next {
case "all", "build", "check", "clean", "fail", "failed", "failing", "install", "lint", "test":
return true
}
case "pytest", "cmake", "ninja", "eslint", "tsc", "vitest", "jest":
return deliveryCommandHasExplicitCue(previous) || next == "fail" || next == "failed" || next == "failing"
}
return false
}
func deliveryCommandHasExplicitCue(previous string) bool {
switch previous {
case "command", "execute", "executing", "run", "running", "using", "with":
return true
default:
return false
}
}
func deliveryCommandHasSubcommand(next string) bool {
switch next {
case "add", "apply", "branch", "build", "check", "checkout", "clean", "clone", "commit", "config", "container",
"deploy", "describe", "destroy", "dev", "diff", "down", "env", "exec", "fetch", "fmt", "generate", "get", "image",
"init", "install", "lint", "list", "log", "logs", "login", "logout", "merge", "mod", "package", "plan", "ps", "publish",
"pull", "push", "rebase", "remote", "remove", "reset", "restore", "run", "serve", "show", "start", "stash", "status",
"switch", "tag", "test", "tool", "uninstall", "up", "update", "upgrade", "version", "vet", "work", "worktree":
return true
default:
return false
}
}
func deliveryTaskClauseHasObservableWork(clause string) bool {
for _, needle := range []string{
"review", "inspect", "analyze", "check", "reproduce", "audit", "verify",
"评审", "审查", "检查", "分析", "复现", "审计", "验证",
} {
affirmative, _ := deliveryTaskNeedleIntent(clause, needle)
if affirmative {
return true
}
}
return false
}
func deliveryTaskClauseIsAdvisory(clause string) bool {
for _, phrase := range deliveryAdvisoryPhrases {
if strings.Contains(clause, phrase) {
return true
}
}
return false
}
func deliveryTaskAdvisoryClauseRequestsMutation(clause string) bool {
advisoryIndex := len(clause)
for _, phrase := range deliveryAdvisoryPhrases {
if index := strings.Index(clause, phrase); index >= 0 && index < advisoryIndex {
advisoryIndex = index
}
}
if advisoryIndex == len(clause) {
return false
}
if deliveryTaskStartsWithMutation(clause[:advisoryIndex]) {
return true
}
for _, cue := range []string{" please ", " then ", " so ", " therefore ", "然后", "所以", "而是", "转而"} {
for rest := clause[advisoryIndex:]; ; {
index := strings.Index(rest, cue)
if index < 0 {
break
}
rest = rest[index+len(cue):]
if deliveryTaskStartsWithMutation(rest) {
return true
}
}
}
for rest, offset := clause[advisoryIndex:], advisoryIndex; ; {
index := strings.Index(rest, "请")
if index < 0 {
break
}
absolute := offset + index
after := clause[absolute+len("请"):]
requestWord := strings.HasSuffix(clause[:absolute], "申") || strings.HasPrefix(after, "求")
if !requestWord && deliveryTaskStartsWithMutation(after) {
return true
}
offset = absolute + len("请")
rest = clause[offset:]
}
for _, cue := range []string{" and ", "并且", "并"} {
if index := strings.LastIndex(clause[advisoryIndex:], cue); index >= 0 {
cueStart := advisoryIndex + index
tail := clause[cueStart+len(cue):]
if !deliveryTaskClauseHasNegation(clause[:cueStart]) && deliveryTaskStartsWithMutation(tail) {
return true
}
}
}
return false
}
func deliveryTaskStartsWithMutation(input string) bool {
input = strings.TrimSpace(input)
for {
stripped := false
for _, prefix := range []string{"please ", "can you ", "could you ", "would you ", "you should ", "帮我", "请你", "直接", "继续", "再"} {
if after, ok := strings.CutPrefix(input, prefix); ok {
input = strings.TrimSpace(after)
stripped = true
break
}
}
if !stripped {
break
}
}
for _, needle := range deliveryMutationNeedles {
if containsTaskNeedle(input, needle) {
if containsNonASCII(needle) {
return strings.HasPrefix(input, needle)
}
tokens := strings.FieldsFunc(input, func(r rune) bool {
return !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') && r != '_' && r != '\''
})
needleTokens := strings.Fields(needle)
if len(tokens) >= len(needleTokens) {
matches := true
for i := range needleTokens {
matches = matches && tokens[i] == needleTokens[i]
}
if matches {
return true
}
}
}
}
return false
}
func deliveryTaskClauseHasNegation(clause string) bool {
clause = strings.ReplaceAll(clause, "’", "'")
for _, phrase := range []string{
" not ", " never ", " without ", "cannot", "can't", " cant ", "don't", " dont ", "won't", " wont ", "unable",
"不要", "别", "勿", "不能", "无法", "不想", "不敢", "无需", "不需要", "不可", "没法", "没有", "禁止", "拒绝",
} {
if strings.Contains(" "+clause+" ", phrase) {
return true
}
}
return false
}
func deliveryTaskClauses(input string) []string {
input = strings.NewReplacer(
" but ", "\n",
" however ", "\n",
" nevertheless ", "\n",
"但请", "\n请",
"但是", "\n",
"不过", "\n",
).Replace(input)
return strings.FieldsFunc(input, func(r rune) bool {
switch r {
case '\n', '\r', '.', '。', ',', ',', ';', ';', '!', '!', '?', '?':
return true
default:
return false
}
})
}
func deliveryMutationClauseNegated(clause string) bool {
for _, phrase := range []string{
"without changing", "without modifying", "analysis only", "review only",
"不要改动", "只分析", "仅分析", "只检查", "仅检查", "只评审", "仅评审",
} {
if strings.Contains(clause, phrase) {
return true
}
}
return false
}
func deliveryTaskNeedleIntent(clause, needle string) (affirmative, negated bool) {
if containsNonASCII(needle) {
for offset := 0; offset < len(clause); {
relative := strings.Index(clause[offset:], needle)
if relative < 0 {
break
}
index := offset + relative
prefix := []rune(clause[:index])
if deliveryMutationRunesNegated(prefix) {
negated = true
} else {
affirmative = true
}
offset = index + len(needle)
}
return affirmative, negated
}
clause = strings.ReplaceAll(clause, "’", "'")
tokens := strings.FieldsFunc(clause, func(r rune) bool {
return !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') && r != '_' && r != '\''
})
needleTokens := strings.Fields(needle)
for i := 0; i+len(needleTokens) <= len(tokens); i++ {
matches := true
for j, token := range needleTokens {
if tokens[i+j] != token {
matches = false
break
}
}
if !matches {
continue
}
if deliveryMutationTokensNegated(tokens[:i]) {
negated = true
} else {
affirmative = true
}
}
return affirmative, negated
}
func deliveryMutationTokensNegated(prefix []string) bool {
if len(prefix) > 6 {
prefix = prefix[len(prefix)-6:]
}
boundary := -1
for i, token := range prefix {
switch token {
case "but", "however", "nevertheless", "instead", "so", "then", "therefore", "please":
boundary = i
}
}
if boundary >= 0 {
prefix = prefix[boundary+1:]
}
for i, token := range prefix {
if token == "not" && i+1 < len(prefix) && prefix[i+1] == "only" {
continue
}
switch token {
case "not", "never", "without", "cannot", "can't", "cant", "don't", "dont", "won't", "wont", "unable", "avoid", "avoiding", "afraid", "refuse", "refusing", "needn't":
return true
case "no":
if i+1 < len(prefix) && prefix[i+1] == "need" {
return true
}
}
}
return false
}
func deliveryMutationRunesNegated(prefix []rune) bool {
if len(prefix) > 12 {
prefix = prefix[len(prefix)-12:]
}
window := string(prefix)
scopeStart := 0
for _, boundary := range []string{"所以", "然后", "而是", "转而", "改为"} {
if index := strings.LastIndex(window, boundary); index >= 0 {
end := index + len(boundary)
if end > scopeStart {
scopeStart = end
}
}
}
if index := strings.LastIndex(window, "请"); index >= 0 {
before, after := window[:index], window[index+len("请"):]
requestWord := strings.HasSuffix(before, "申") || strings.HasPrefix(after, "求")
negatedRequest := false
for _, marker := range []string{"不要", "不能", "无法", "不想", "不敢", "无需", "不需要", "不可", "没法", "禁止", "拒绝"} {
if strings.HasSuffix(before, marker) || strings.Contains(after, marker) {
negatedRequest = true
break
}
}
if !requestWord && !negatedRequest && index+len("请") > scopeStart {
scopeStart = index + len("请")
}
}
window = window[scopeStart:]
for _, marker := range []string{"不要", "别", "勿", "不能", "无法", "不想", "不敢", "无需", "不需要", "不可", "没法", "没有", "禁止", "拒绝"} {
if strings.Contains(window, marker) {
return true
}
}
return false
}
func containsAnySubstring(s string, terms []string) bool {
for _, term := range terms {
if strings.Contains(s, term) {
return true
}
}
return false
}
+16 -54
View File
@@ -1,6 +1,11 @@
// Package taskpolicy builds the host-side TaskPolicy that freezes planning,
// verification, review, and natural-language constraints for one turn before
// the first model request. It never calls a classification model.
//
// Two things bind a turn: the frozen role setting, and constraints the user
// stated outright ("don't modify", "only run go test"). Risk arrives as
// host-computed signals from the caller — guessing it from topic words would
// impose ceremony on a sentence rather than on the work.
package taskpolicy
import (
@@ -10,15 +15,11 @@ import (
"reasonix/internal/agentpreset"
"reasonix/internal/shellparse"
"reasonix/internal/taskintent"
)
// PolicyVersion is the diagnostic version stamped on every TaskPolicy.
const PolicyVersion = agentpreset.PolicyVersion
// Intent is the host's coarse task intent for the turn.
type Intent = taskintent.Intent
// Risk is the turn risk level; it may only ratchet upward within a turn.
type Risk uint8
@@ -78,8 +79,8 @@ type Constraints struct {
// fenced regions must already be stripped by StripQuotedConstraints or left
// intact so Derive ignores them.
type Input struct {
// Raw is the original user text (including quotes/fences). Used only for
// intent classification when RawForIntent is empty.
// Raw is the original user text (including quotes/fences); the fallback
// source when Instruction is empty.
Raw string
// Instruction is user text with quoted/fenced content removed so constraint
// phrases inside citations cannot bind the host.
@@ -89,6 +90,8 @@ type Input struct {
// PlanMode is the collaboration plan-mode flag.
PlanMode bool
// HighRiskHints are host signals (permission/auth/release/security class).
// The host computes them from what the turn touches; this package never
// re-derives risk from the text itself.
HighRiskHints bool
// MediumRiskHints are host signals (cross-module / migration).
MediumRiskHints bool
@@ -104,7 +107,6 @@ type Input struct {
// TaskPolicy is the authoritative host policy for one turn.
type TaskPolicy struct {
Preset agentpreset.AgentPreset
Intent Intent
Risk Risk
Route Route
Constraints Constraints
@@ -115,8 +117,6 @@ type TaskPolicy struct {
SecurityClass bool
// PolicyVersion is diagnostic only.
PolicyVersion int
// RequireAtomicContract forces an Atomic TaskContract on direct runs.
RequireAtomicContract bool
// AllowExploreSubagent mirrors the preset capability for explore workers.
AllowExploreSubagent bool
// SemanticRouterAllowed mirrors the preset capability for LLM routing.
@@ -132,11 +132,6 @@ func Derive(in Input) TaskPolicy {
if instruction == "" {
instruction = StripQuotedConstraints(in.Raw)
}
rawIntent := strings.TrimSpace(in.Raw)
if rawIntent == "" {
rawIntent = instruction
}
intent := taskintent.Classify(rawIntent)
constraints := parseConstraints(instruction)
if in.PlanMode {
@@ -145,19 +140,15 @@ func Derive(in Input) TaskPolicy {
constraints.Notes = append(constraints.Notes, "plan_mode_read_only")
}
securityClass := in.HighRiskHints || isSecurityClass(instruction)
securityClass := in.HighRiskHints
risk := RiskLow
if in.MediumRiskHints || in.MultiFile || in.CrossSurface || in.Structured {
risk = RiskMedium
}
if in.HighRiskHints || securityClass || isHighRisk(instruction) {
if in.HighRiskHints {
risk = RiskHigh
}
// Intent-driven floor: mutations start at least low; external/persist medium.
if intent == taskintent.PersistentAction && risk < RiskMedium {
risk = RiskMedium
}
route := chooseRoute(policy, intent, risk, in)
route := chooseRoute(policy, risk, in)
verification := policy.VerificationPolicy.Level
if constraints.RequireFullVerification || !constraints.ForbidTests && risk >= RiskHigh {
if policy.VerificationPolicy.Level < VerifyFull && (constraints.RequireFullVerification || risk >= RiskHigh) {
@@ -176,11 +167,6 @@ func Derive(in Input) TaskPolicy {
if preset == agentpreset.Delivery && risk >= RiskMedium {
verification = VerifyFull
}
if intent == taskintent.Conversation || intent == taskintent.Advisory {
if !in.MultiFile && risk == RiskLow {
verification = VerifyNone
}
}
review := policy.ReviewForRisk(int(risk), securityClass)
if constraints.ForbidMutation {
@@ -190,7 +176,6 @@ func Derive(in Input) TaskPolicy {
return TaskPolicy{
Preset: preset,
Intent: intent,
Risk: risk,
Route: route,
Constraints: constraints,
@@ -198,7 +183,6 @@ func Derive(in Input) TaskPolicy {
Review: review,
SecurityClass: securityClass,
PolicyVersion: PolicyVersion,
RequireAtomicContract: policy.PlannerPolicy.RequireAtomicContract || (preset == agentpreset.Delivery && intent == taskintent.Mutation),
AllowExploreSubagent: policy.PlannerPolicy.AllowExploreSubagent && risk > RiskLow,
SemanticRouterAllowed: policy.CapabilityPolicy.SemanticRouterAllowed && (risk >= RiskMedium || securityClass),
}
@@ -297,11 +281,9 @@ func (p TaskPolicy) RequiresSecurityReview() bool {
return p.Review == ReviewForcedSecurity
}
func chooseRoute(policy agentpreset.PresetPolicy, intent Intent, risk Risk, in Input) Route {
if intent == taskintent.Conversation || intent == taskintent.Advisory {
if !in.MultiFile && !in.Structured && risk == RiskLow {
return RouteDirect
}
func chooseRoute(policy agentpreset.PresetPolicy, risk Risk, in Input) Route {
if risk == RiskLow && !in.MultiFile && !in.Structured && !in.CrossSurface {
return RouteDirect
}
if risk >= RiskHigh && policy.PlannerPolicy.FullPlanOnHighRisk {
return RouteFullPlan
@@ -314,7 +296,7 @@ func chooseRoute(policy agentpreset.PresetPolicy, intent Intent, risk Risk, in I
return RouteFullPlan
}
}
if in.MultiFile || in.Structured || (!in.Anchored && intent == taskintent.Mutation) {
if in.MultiFile || in.Structured {
if policy.PlannerPolicy.PreferLightPlan {
return RouteLightPlan
}
@@ -410,26 +392,6 @@ func matchesAny(lower string, needles []string) bool {
return false
}
func isSecurityClass(instruction string) bool {
lower := strings.ToLower(instruction)
return matchesAny(lower, []string{
"security", "authentication", "authorization", "permission model",
"credential", "secret", "oauth", "jwt", "cve", "xss", "sqli",
"privilege escalation", "release to production", "publish package",
"deploy to production", "production deploy",
"安全漏洞", "权限提升", "认证绕过", "鉴权", "凭证泄露", "密钥泄露", "上线发布", "生产环境",
})
}
func isHighRisk(instruction string) bool {
lower := strings.ToLower(instruction)
return matchesAny(lower, []string{
"migrate", "migration", "drop table", "rm -rf", "force push",
"destructive", "irreversible", "production data",
"迁移", "删库", "不可逆", "生产数据",
}) || isSecurityClass(instruction)
}
// StripQuotedConstraints removes fenced code blocks and quoted spans so
// constraint phrases inside citations do not bind the host.
func StripQuotedConstraints(raw string) string {
+18 -8
View File
@@ -5,7 +5,6 @@ import (
"testing"
"reasonix/internal/agentpreset"
"reasonix/internal/taskintent"
)
func TestDeriveLightSimpleIsDirect(t *testing.T) {
@@ -13,9 +12,6 @@ func TestDeriveLightSimpleIsDirect(t *testing.T) {
Raw: "what is a mutex?",
Preset: agentpreset.Light,
})
if p.Intent != taskintent.Conversation && p.Intent != taskintent.Advisory {
t.Fatalf("intent = %v", p.Intent)
}
if p.Route != RouteDirect {
t.Fatalf("route = %v, want direct", p.Route)
}
@@ -26,8 +22,9 @@ func TestDeriveLightSimpleIsDirect(t *testing.T) {
func TestDeriveLightHighRiskElevates(t *testing.T) {
p := Derive(Input{
Raw: "fix the authentication bypass in production login",
Preset: agentpreset.Light,
Raw: "fix the authentication bypass in production login",
Preset: agentpreset.Light,
HighRiskHints: true,
})
if p.Risk < RiskHigh && !p.SecurityClass {
t.Fatalf("expected high risk or security class, got risk=%v security=%v", p.Risk, p.SecurityClass)
@@ -56,8 +53,21 @@ func TestDeriveDeliveryLowRiskNoReviewer(t *testing.T) {
if p.Risk == RiskLow && p.RequiresIndependentReview() {
t.Fatal("delivery low-risk must not force independent reviewer")
}
if !p.RequireAtomicContract {
t.Fatal("delivery mutation should require atomic contract")
}
// Risk arrives as a host signal. Topic words used to promote it on their own,
// so "migrate the schema" bought a full plan and a forced review from a
// sentence; the same text with no host signal must now stay low.
func TestDeriveIgnoresTopicWordsWithoutHostSignals(t *testing.T) {
for _, raw := range []string{
"migrate the schema to the new column layout",
"explain how the oauth credential rotation works",
"迁移这段配置到新格式",
} {
p := Derive(Input{Raw: raw, Preset: agentpreset.Balanced})
if p.Risk != RiskLow || p.SecurityClass {
t.Errorf("%q derived risk=%v security=%v from words alone", raw, p.Risk, p.SecurityClass)
}
}
}
+31 -57
View File
@@ -22,18 +22,17 @@ const SchemaVersion = 1
// Record is one observed occurrence. Exactly one payload field is set; Seq
// orders them and TS is the unix-millisecond observation time at the recorder.
type Record struct {
SchemaVersion int `json:"schema_version"`
Seq uint64 `json:"seq"`
TS int64 `json:"ts"`
Event *eventwire.Event `json:"event,omitempty"`
ReadinessAudit *ReadinessAudit `json:"readiness_audit,omitempty"`
ProtocolRecovery string `json:"protocol_recovery,omitempty"`
TurnCompletion bool `json:"turn_completion,omitempty"`
ContractShadow *ContractShadowAudit `json:"contract_shadow,omitempty"`
CompletionReport *CompletionReport `json:"completion_report,omitempty"`
OutcomeProgress *OutcomeProgress `json:"outcome_progress,omitempty"`
DelegationAdmission *DelegationAdmission `json:"delegation_admission,omitempty"`
MemoryRecall *MemoryRecall `json:"memory_recall,omitempty"`
SchemaVersion int `json:"schema_version"`
Seq uint64 `json:"seq"`
TS int64 `json:"ts"`
Event *eventwire.Event `json:"event,omitempty"`
ReadinessAudit *ReadinessAudit `json:"readiness_audit,omitempty"`
ProtocolRecovery string `json:"protocol_recovery,omitempty"`
TurnCompletion bool `json:"turn_completion,omitempty"`
ContractShadow *ContractShadowAudit `json:"contract_shadow,omitempty"`
CompletionReport *CompletionReport `json:"completion_report,omitempty"`
OutcomeProgress *OutcomeProgress `json:"outcome_progress,omitempty"`
MemoryRecall *MemoryRecall `json:"memory_recall,omitempty"`
}
// MemoryRecall mirrors event.MemoryRecallAudit with stable snake_case keys.
@@ -55,31 +54,18 @@ type MemoryRecallHit struct {
Score float64 `json:"score,omitempty"`
}
// DelegationAdmission mirrors event.DelegationAdmissionAudit with stable keys.
type DelegationAdmission struct {
Tool string `json:"tool"`
Verdict string `json:"verdict"`
Reason string `json:"reason,omitempty"`
Intent string `json:"intent,omitempty"`
}
// OutcomeProgress mirrors evidence.OutcomeSample with stable snake_case keys.
type OutcomeProgress struct {
Round int `json:"round"`
Exploration int `json:"exploration,omitempty"`
Verification int `json:"verification,omitempty"`
Objective int `json:"objective,omitempty"`
Regression int `json:"regression,omitempty"`
Churn int `json:"churn,omitempty"`
LegacyGain int `json:"legacy_gain,omitempty"`
Discriminating int `json:"discriminating,omitempty"`
DebtAge int `json:"debt_age,omitempty"`
BlindMutations int `json:"blind_mutations,omitempty"`
EBMEligible bool `json:"ebm_eligible,omitempty"`
EBMFired bool `json:"ebm_fired,omitempty"`
LocalExecSeen bool `json:"local_exec_seen,omitempty"`
GovernorEligible bool `json:"governor_eligible,omitempty"`
GovernorEngaged bool `json:"governor_engaged,omitempty"`
Round int `json:"round"`
Exploration int `json:"exploration,omitempty"`
Verification int `json:"verification,omitempty"`
Objective int `json:"objective,omitempty"`
Regression int `json:"regression,omitempty"`
Churn int `json:"churn,omitempty"`
LegacyGain int `json:"legacy_gain,omitempty"`
Discriminating int `json:"discriminating,omitempty"`
DebtAge int `json:"debt_age,omitempty"`
BlindMutations int `json:"blind_mutations,omitempty"`
}
// ContractShadowAudit mirrors event.ContractShadowAudit with stable keys.
@@ -245,21 +231,16 @@ func (r *Recorder) RecordCompletionReport(a event.CompletionReportAudit) {
func (r *Recorder) RecordOutcomeProgress(sample evidence.OutcomeSample) {
r.append(Record{OutcomeProgress: &OutcomeProgress{
Round: sample.Round,
Exploration: sample.Exploration,
Verification: sample.Verification,
Objective: sample.Objective,
Regression: sample.Regression,
Churn: sample.Churn,
LegacyGain: sample.LegacyGain,
Discriminating: sample.Discriminating,
DebtAge: sample.DebtAge,
BlindMutations: sample.BlindMutations,
EBMEligible: sample.EBMEligible,
EBMFired: sample.EBMFired,
LocalExecSeen: sample.LocalExecSeen,
GovernorEligible: sample.GovernorEligible,
GovernorEngaged: sample.GovernorEngaged,
Round: sample.Round,
Exploration: sample.Exploration,
Verification: sample.Verification,
Objective: sample.Objective,
Regression: sample.Regression,
Churn: sample.Churn,
LegacyGain: sample.LegacyGain,
Discriminating: sample.Discriminating,
DebtAge: sample.DebtAge,
BlindMutations: sample.BlindMutations,
}})
event.RecordOutcomeProgress(r.inner, sample)
}
@@ -279,13 +260,6 @@ func (r *Recorder) RecordMemoryRecall(a event.MemoryRecallAudit) {
event.RecordMemoryRecall(r.inner, a)
}
func (r *Recorder) RecordDelegationAdmission(a event.DelegationAdmissionAudit) {
r.append(Record{DelegationAdmission: &DelegationAdmission{
Tool: a.Tool, Verdict: a.Verdict, Reason: a.Reason, Intent: a.Intent,
}})
event.RecordDelegationAdmission(r.inner, a)
}
func (r *Recorder) RecordProtocolRecovery(a event.ProtocolRecoveryAudit) {
r.append(Record{ProtocolRecovery: string(a.Kind)})
event.RecordProtocolRecovery(r.inner, a)
+4 -8
View File
@@ -114,7 +114,6 @@ func TestRecorderRecordsAndForwardsOptionalCapabilities(t *testing.T) {
r.RecordProtocolRecovery(event.ProtocolRecoveryAudit{Kind: event.ProtocolRecoveryMissingReasoningDetected})
r.RecordTurnCompletion()
r.RecordOutcomeProgress(evidence.OutcomeSample{Round: 3, Exploration: 2, Objective: 1, LegacyGain: 4})
r.RecordDelegationAdmission(event.DelegationAdmissionAudit{Tool: "research", Verdict: "deny", Reason: "local_fix_no_external_need", Intent: "mutation"})
r.RecordCompletionReport(event.CompletionReportAudit{
Verdict: "partial", Changes: 2, ChangesUnreviewed: 1, Gaps: 1, GapKinds: []string{"unreviewed_change"},
})
@@ -123,8 +122,8 @@ func TestRecorderRecordsAndForwardsOptionalCapabilities(t *testing.T) {
}
recs := readRecords(t, path)
if len(recs) != 6 {
t.Fatalf("got %d records, want 6", len(recs))
if len(recs) != 5 {
t.Fatalf("got %d records, want 5", len(recs))
}
if recs[0].ReadinessAudit == nil || recs[0].ReadinessAudit.Result != "blocked" || recs[0].ReadinessAudit.MissingVerification != 2 {
t.Errorf("readiness record = %+v", recs[0].ReadinessAudit)
@@ -138,11 +137,8 @@ func TestRecorderRecordsAndForwardsOptionalCapabilities(t *testing.T) {
if recs[3].OutcomeProgress == nil || recs[3].OutcomeProgress.Round != 3 || recs[3].OutcomeProgress.Objective != 1 || recs[3].OutcomeProgress.LegacyGain != 4 {
t.Errorf("outcome progress record = %+v", recs[3].OutcomeProgress)
}
if recs[4].DelegationAdmission == nil || recs[4].DelegationAdmission.Verdict != "deny" || recs[4].DelegationAdmission.Tool != "research" {
t.Errorf("delegation admission record = %+v", recs[4].DelegationAdmission)
}
if rec := recs[5].CompletionReport; rec == nil || rec.Verdict != "partial" || rec.ChangesUnreviewed != 1 || len(rec.GapKinds) != 1 {
t.Errorf("completion report record = %+v", recs[5].CompletionReport)
if rec := recs[4].CompletionReport; rec == nil || rec.Verdict != "partial" || rec.ChangesUnreviewed != 1 || len(rec.GapKinds) != 1 {
t.Errorf("completion report record = %+v", recs[4].CompletionReport)
}
if len(inner.readiness) != 1 || len(inner.recoveries) != 1 || inner.turns != 1 || len(inner.outcomes) != 1 || len(inner.reports) != 1 {
t.Errorf("inner capabilities = %d/%d/%d/%d/%d, want 1/1/1/1/1", len(inner.readiness), len(inner.recoveries), inner.turns, len(inner.outcomes), len(inner.reports))
-6
View File
@@ -1807,12 +1807,6 @@
"internal/taskcontract/taskcontract.go": {
"essay": 1
},
"internal/taskintent/goal_budget.go": {
"essay": 1
},
"internal/taskintent/heuristic.go": {
"essay": 1
},
"internal/taskmonitor/control.go": {
"complexity": 21,
"essay": 1,
+1 -1
View File
@@ -43,8 +43,8 @@ var leaves = []string{
"internal/shellparse",
"internal/store",
"internal/sysproxy",
"internal/taskintent",
"internal/textutil",
"internal/tokencount",
"internal/workspacelease",
}