diff --git a/docs/GUIDE.md b/docs/GUIDE.md index fc8290c6b9..2ea709cd28 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -180,6 +180,14 @@ tool_timeout_seconds = { "generate_video" = 1800 } # optional raw MCP tool nam For the full schema and every field's contract, see [`SPEC.md` §5](./SPEC.md#5-configuration-toml). +The startup tool summary reuses a trusted snapshot in the current Reasonix data +directory for up to 24 hours. Probes refreshed by another data directory do not +replace that valid snapshot. + +On expiry, transient probe failures retain previous +successful observations, keeping the system prefix stable across rebuilds and +restarts; definitive changes, such as a removed tool, are adopted on refresh. + Installed and project-configured MCP servers need no per-tool trust list. The dedicated two-model Planner may use every non-destructive MCP tool, even when the server omits `readOnlyHint`; strict read-only sub-agents still diff --git a/docs/GUIDE.zh-CN.md b/docs/GUIDE.zh-CN.md index 5d52fe8a7b..8826df1475 100644 --- a/docs/GUIDE.zh-CN.md +++ b/docs/GUIDE.zh-CN.md @@ -189,6 +189,10 @@ tool_timeout_seconds = { "generate_video" = 1800 } # 可选:raw MCP tool 名 完整 schema 与每个字段的契约见 [`SPEC.md` §5](./SPEC.md#5-configuration-toml)。 +启动工具摘要会复用当前 Reasonix 数据目录中仍可信的快照,有效期为 24 小时。其他数据目录 +刷新探测结果不会替换这份有效快照。快照过期后,临时探测失败会保留先前的成功记录,让系统 +前缀在运行时重建和应用重启之间保持稳定;工具被移除等确定性变化会在刷新时生效。 + 已安装或由项目配置声明的 MCP server 不需要逐工具信任名单。独立双模型 Planner 可使用所有 非 destructive 工具,即使 server 没有声明 `readOnlyHint`;严格只读 subagent 仍要求 `readOnlyHint: true` 且无 `destructiveHint`。 diff --git a/internal/assembly/boot/compaction_system_change_effect_test.go b/internal/assembly/boot/compaction_system_change_effect_test.go index 4af27bb319..dc38361ad9 100644 --- a/internal/assembly/boot/compaction_system_change_effect_test.go +++ b/internal/assembly/boot/compaction_system_change_effect_test.go @@ -73,6 +73,9 @@ func adoptCollectingNotices(t *testing.T, from *control.Controller, home, dir st if err := ApplyRuntimeMigration(to, from, CaptureRuntimeMigration(from)); err != nil { t.Fatalf("ApplyRuntimeMigration: %v", err) } + if from.History()[0].Content != to.History()[0].Content { + t.Logf("system prompt changed during migration: %q", firstDivergence(from.History()[0].Content, to.History()[0].Content)) + } return notices } diff --git a/internal/assembly/boot/environment_snapshot_effect_test.go b/internal/assembly/boot/environment_snapshot_effect_test.go new file mode 100644 index 0000000000..c96af4df4c --- /dev/null +++ b/internal/assembly/boot/environment_snapshot_effect_test.go @@ -0,0 +1,87 @@ +package boot + +import ( + "context" + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "reasonix/internal/base/testenv" + "reasonix/internal/contract/event" + "reasonix/internal/platform/environment" +) + +func environmentSnapshotPrompt(t *testing.T, home, dir string) string { + t.Helper() + c, err := Build(context.Background(), Options{Home: home, WorkspaceRoot: dir, resolvedShell: pinnedEffectShell(), Sink: event.Discard}) + if err != nil { + t.Fatal(err) + } + defer c.Close() + return systemMessage(c.History()) +} + +func TestBuildEnvironmentSnapshotHelper(t *testing.T) { + args := flag.Args() + if len(args) != 3 { + return + } + if err := os.WriteFile(args[2], []byte(environmentSnapshotPrompt(t, args[0], args[1])), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestBuildKeepsEnvironmentSnapshotAcrossOtherHomeRefresh(t *testing.T) { + home, dir := statedBootHome(t), testenv.TempDir(t) + tool := filepath.Join(testenv.TempDir(t), "probe-tool") + if runtime.GOOS == "windows" { + tool += ".bat" + } + writeTool := func(version string) { + t.Helper() + body := "#!/bin/sh\nprintf '%s\\n' '" + version + "'\n" + if runtime.GOOS == "windows" { + body = "@echo " + version + "\r\n" + } + if err := os.WriteFile(tool, []byte(body), 0o755); err != nil { + t.Fatal(err) + } + } + writeTool("probe version one") + var config strings.Builder + config.WriteString("default_model = 'test'\n[[providers]]\nname = 'test'\nkind = 'openai'\nmodel = 'test'\nbase_url = 'https://example.invalid'\n[environment.tools]\n") + for _, command := range environment.DefaultProbes() { + fmt.Fprintf(&config, "%s = %q\n", strings.Fields(command)[0], tool) + } + writeFile(t, dir, "reasonix.toml", config.String()) + approveWorkspaceAt(t, home, dir) + promptFile := filepath.Join(testenv.TempDir(t), "prompt.txt") + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestBuildEnvironmentSnapshotHelper$", "--", home, dir, promptFile) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("initial process: %v\n%s", err, output) + } + initial, err := os.ReadFile(promptFile) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(initial), "probe version one") { + t.Fatal("initial build did not render the fixture's tool versions") + } + writeTool("probe version two") + otherHome := statedBootHome(t) + approveWorkspaceAt(t, otherHome, dir) + if other := environmentSnapshotPrompt(t, otherHome, dir); !strings.Contains(other, "probe version two") { + t.Fatal("new home did not probe the changed tool") + } + if rebuilt := environmentSnapshotPrompt(t, home, dir); rebuilt != string(initial) { + t.Fatalf("another home's shared probes changed the persisted prefix: %q", firstDivergence(string(initial), rebuilt)) + } +} diff --git a/internal/platform/environment/probe.go b/internal/platform/environment/probe.go index b999ecce24..7418ef6b29 100644 --- a/internal/platform/environment/probe.go +++ b/internal/platform/environment/probe.go @@ -94,30 +94,24 @@ func DefaultProbes() []string { func RunProbesWithOptions(ctx context.Context, commands []string, opts ProbeOptions) []ProbeResult { key := probeFingerprint(commands, opts) now := probeNow() + snapshot, hasSnapshot := loadProbeSnapshot(opts.SnapshotDir, key) + if hasSnapshot && now.Sub(snapshot.StoredAt) < probeSnapshotTTL && reusableHere(snapshot.Results, opts.DenyRoots) { + return cloneProbeResults(snapshot.Results) + } if results, ok := cachedProbeResults(key, now); ok && reusableHere(results, opts.DenyRoots) { - adoptSnapshot(opts.SnapshotDir, key, results, now) - return results + return adoptSharedProbe(opts, key, snapshot, results, now) } if call, ok := beginProbe(key); ok { <-call.done if reusableHere(call.results, opts.DenyRoots) { - adoptSnapshot(opts.SnapshotDir, key, call.results, now) - return cloneProbeResults(call.results) + return adoptSharedProbe(opts, key, snapshot, call.results, now) } // Someone else's answer ran something this workspace will not. Its own // run below is not shared back, so it neither reads nor writes the key. return runProbesUncached(ctx, commands, opts) } - // A fresh persisted snapshot substitutes for a live run entirely: rebuilds - // and app relaunches within the TTL render the exact bytes the sessions on - // this machine were recorded with, so the provider prefix cache survives. - snapshot, hasSnapshot := loadProbeSnapshot(opts.SnapshotDir, key) - if hasSnapshot && now.Sub(snapshot.StoredAt) < probeSnapshotTTL && reusableHere(snapshot.Results, opts.DenyRoots) { - finishProbe(key, snapshot.Results, now) - return cloneProbeResults(snapshot.Results) - } results := runProbesUncached(ctx, commands, opts) - if hasSnapshot { + if hasSnapshot && reusableHere(snapshot.Results, opts.DenyRoots) { // Even an expired snapshot anchors the flap merge: transient failures // (timeout, nonzero exit) keep the previous successful observation so // a slow tool cannot rewrite the prompt prefix. diff --git a/internal/platform/environment/snapshot.go b/internal/platform/environment/snapshot.go index 8a5bb990ee..0dc746b049 100644 --- a/internal/platform/environment/snapshot.go +++ b/internal/platform/environment/snapshot.go @@ -144,12 +144,13 @@ func mergeProbeSnapshot(previous, fresh []ProbeResult) []ProbeResult { return merged } -// adoptSnapshot records an answer this snapshot directory was served from -// memory, so that when the in-memory entry expires the directory re-serves the -// same bytes instead of probing again. An existing snapshot is left alone. -func adoptSnapshot(dir, fingerprint string, results []ProbeResult, now time.Time) { - if _, ok := loadProbeSnapshot(dir, fingerprint); ok { - return +// Shared results refresh only missing or expired snapshots. A previous trusted +// success still anchors transient-failure merging, whichever caller probed. +func adoptSharedProbe(opts ProbeOptions, fingerprint string, previous probeSnapshot, results []ProbeResult, now time.Time) []ProbeResult { + results = cloneProbeResults(results) + if reusableHere(previous.Results, opts.DenyRoots) { + results = mergeProbeSnapshot(previous.Results, results) } - saveProbeSnapshot(dir, fingerprint, results, now) + saveProbeSnapshot(opts.SnapshotDir, fingerprint, results, now) + return results } diff --git a/internal/platform/environment/snapshot_owner_test.go b/internal/platform/environment/snapshot_owner_test.go new file mode 100644 index 0000000000..27a4bdfd21 --- /dev/null +++ b/internal/platform/environment/snapshot_owner_test.go @@ -0,0 +1,74 @@ +package environment + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "reasonix/internal/base/testenv" +) + +func TestSnapshotOwnerKeepsItsAnswerAfterAnotherHomeRefreshes(t *testing.T) { + now := time.Unix(1000, 0) + resetProbeCacheForTest(t, now) + tool := filepath.Join(testenv.TempDir(t), "ownertool") + toolPath := writeProbeTool(t, tool, "version one") + commands := []string{"ownertool --version"} + firstHome := ProbeOptions{Overrides: map[string]string{"ownertool": toolPath}, SnapshotDir: testenv.TempDir(t)} + otherHome := firstHome + otherHome.SnapshotDir = testenv.TempDir(t) + first := RunProbesWithOptions(context.Background(), commands, firstHome) + if len(first) != 1 || !first[0].Found || first[0].Output != "version one" { + t.Fatalf("initial probe = %+v", first) + } + setProbeNowForTest(now.Add(probeCacheTTL + time.Second)) + writeProbeTool(t, tool, "version two") + other := RunProbesWithOptions(context.Background(), commands, otherHome) + if len(other) != 1 || other[0].Output != "version two" { + t.Fatalf("new home must refresh the expired shared cache: %+v", other) + } + rebuilt := RunProbesWithOptions(context.Background(), commands, firstHome) + if FormatSection(rebuilt, "test/os", "/bin/sh", nil) != FormatSection(first, "test/os", "/bin/sh", nil) { + t.Fatalf("another home's cache replaced a fresh persisted answer: first=%+v rebuilt=%+v", first, rebuilt) + } +} + +func TestSharedRefreshKeepsTheExpiredOwnersSuccessfulObservation(t *testing.T) { + now := time.Unix(1000, 0) + resetProbeCacheForTest(t, now) + tool := filepath.Join(testenv.TempDir(t), "refreshowner") + toolPath := writeProbeTool(t, tool, "version one") + commands := []string{"refreshowner --version"} + owner := ProbeOptions{Overrides: map[string]string{"refreshowner": toolPath}, SnapshotDir: testenv.TempDir(t)} + first := RunProbesWithOptions(context.Background(), commands, owner) + if len(first) != 1 || !first[0].Found { + t.Fatalf("first observation = %+v", first) + } + setProbeNowForTest(now.Add(probeSnapshotTTL + time.Second)) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + other := owner + other.SnapshotDir = testenv.TempDir(t) + failed := RunProbesWithOptions(ctx, commands, other) + if len(failed) != 1 || !transientProbeFailure(failed[0]) { + t.Fatalf("canceled probe must be transient: %+v", failed) + } + got := RunProbesWithOptions(context.Background(), commands, owner) + if len(got) != 1 || !got[0].Found || got[0].Output != first[0].Output { + t.Fatalf("shared transient result erased the owner's successful observation: %+v", got) + } + snapshot, ok := loadProbeSnapshot(owner.SnapshotDir, probeFingerprint(commands, owner)) + if !ok || snapshot.Results[0].Output != first[0].Output || !snapshot.StoredAt.Equal(probeNow()) { + t.Fatalf("refreshed snapshot = %+v", snapshot) + } + resetProbeCacheForTest(t, probeNow().Add(time.Second)) + if err := os.Remove(toolPath); err != nil { + t.Fatal(err) + } + restarted := RunProbesWithOptions(context.Background(), commands, owner) + if len(restarted) != 1 || restarted[0].Output != first[0].Output { + t.Fatalf("restart lost the merged answer: %+v", restarted) + } +} diff --git a/internal/platform/environment/snapshot_test.go b/internal/platform/environment/snapshot_test.go index b35dd42ab4..509e3d93c9 100644 --- a/internal/platform/environment/snapshot_test.go +++ b/internal/platform/environment/snapshot_test.go @@ -171,7 +171,7 @@ func TestProbeCacheHitPersistsSnapshotSoExpiryKeepsTheAnswer(t *testing.T) { } } -func TestProbeCacheHitLeavesAnExistingSnapshotAlone(t *testing.T) { +func TestFreshSnapshotTakesPrecedenceOverSharedMemory(t *testing.T) { resetProbeCacheForTest(t, time.Unix(1000, 0)) dir := testenv.TempDir(t) toolPath := writeProbeTool(t, filepath.Join(dir, "keeptool"), "keeptool 1.0") @@ -182,7 +182,10 @@ func TestProbeCacheHitLeavesAnExistingSnapshotAlone(t *testing.T) { saveProbeSnapshot(opts.SnapshotDir, key, []ProbeResult{{Command: commands[0], Binary: "keeptool", Found: true, Output: "keeptool 0.9", Path: toolPath}}, time.Unix(900, 0)) finishProbe(key, []ProbeResult{{Command: commands[0], Binary: "keeptool", Found: true, Output: "keeptool 1.0", Path: toolPath}}, time.Unix(1000, 0)) - RunProbesWithOptions(context.Background(), commands, opts) + got := RunProbesWithOptions(context.Background(), commands, opts) + if len(got) != 1 || got[0].Output != "keeptool 0.9" { + t.Fatalf("shared memory overrode the snapshot's rendered answer: %+v", got) + } snap, ok := loadProbeSnapshot(opts.SnapshotDir, key) if !ok || len(snap.Results) != 1 || snap.Results[0].Output != "keeptool 0.9" || !snap.StoredAt.Equal(time.Unix(900, 0)) { t.Fatalf("a cache hit rewrote the snapshot it found: %+v", snap) diff --git a/release-notes/studio/unreleased/14676-environment-snapshot-precedence.md b/release-notes/studio/unreleased/14676-environment-snapshot-precedence.md new file mode 100644 index 0000000000..e3cce634a5 --- /dev/null +++ b/release-notes/studio/unreleased/14676-environment-snapshot-precedence.md @@ -0,0 +1,2 @@ +- 修复:启动环境摘要优先复用当前数据目录的有效可信快照,避免其他目录的探测结果改变系统提示。此前使用了其他目录结果的会话可能发生一次前缀切换,并在首次请求时降低缓存命中率。修复 #14676。 +- Fixed: startup summaries prefer each data home's valid trusted snapshot. Previously affected sessions may change prefix once, reducing cache reuse for the first request. Fixes #14676.