mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 17:10:43 -10:00
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:
1 parent
7469d77073
commit
b071fef950
67 files changed
+311
-3693
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -91,7 +91,6 @@ type trajScan struct {
|
||||
|
||||
gapReason, gapCompl, gapPrompt int64
|
||||
|
||||
denyDelegations map[string]bool
|
||||
delegationToolMs map[string]int64
|
||||
}
|
||||
|
||||
|
||||
+10
-40
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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 := ¬iceSink{}
|
||||
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 := ¬iceSink{}
|
||||
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: ¬iceSink{}}}
|
||||
results, outcomes := arbiterRound()
|
||||
|
||||
got := a.applyInterventions(results, outcomes,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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, "; ")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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](),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
}}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -43,8 +43,8 @@ var leaves = []string{
|
||||
"internal/shellparse",
|
||||
"internal/store",
|
||||
"internal/sysproxy",
|
||||
"internal/taskintent",
|
||||
"internal/textutil",
|
||||
"internal/tokencount",
|
||||
"internal/workspacelease",
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user