From e309ee22004707f418c8a1bdb1943a6341944661 Mon Sep 17 00:00:00 2001 From: xiexian <3468714225@qq.com> Date: Fri, 2 Oct 2026 13:12:18 +0800 Subject: [PATCH] docs: add a packaged read-only subagent exercise --- docs/PLUGIN_PACKAGES.md | 2 + examples/repo-map-kit/README.md | 118 ++++++++ examples/repo-map-kit/agents/map.md | 28 ++ examples/repo-map-kit/fixture/NOTES.md | 14 + examples/repo-map-kit/fixture/go.mod | 3 + examples/repo-map-kit/fixture/loader.go | 13 + examples/repo-map-kit/reasonix-plugin.json | 9 + .../boot/effect_repo_map_example_test.go | 262 ++++++++++++++++++ 8 files changed, 449 insertions(+) create mode 100644 examples/repo-map-kit/README.md create mode 100644 examples/repo-map-kit/agents/map.md create mode 100644 examples/repo-map-kit/fixture/NOTES.md create mode 100644 examples/repo-map-kit/fixture/go.mod create mode 100644 examples/repo-map-kit/fixture/loader.go create mode 100644 examples/repo-map-kit/reasonix-plugin.json create mode 100644 internal/assembly/boot/effect_repo_map_example_test.go diff --git a/docs/PLUGIN_PACKAGES.md b/docs/PLUGIN_PACKAGES.md index f35237aac6..a787a2cf33 100644 --- a/docs/PLUGIN_PACKAGES.md +++ b/docs/PLUGIN_PACKAGES.md @@ -16,6 +16,8 @@ The [multi-file skill example](../examples/release-note-kit/README.md) shows how a plugin keeps a required reference file with its skill. For an issue-to-PR-draft exercise with a deliberately failing local fixture, see the [repository workflow example](../examples/issue-fix-kit/README.md). +For a packaged read-only subagent, local inputs and lifecycle verification, +see the [repository orientation example](../examples/repo-map-kit/README.md). For a frontend task with a brief, sample data, and browser acceptance steps, see the [local interface example](../examples/frontend-page-kit/README.md). diff --git a/examples/repo-map-kit/README.md b/examples/repo-map-kit/README.md new file mode 100644 index 0000000000..232938714c --- /dev/null +++ b/examples/repo-map-kit/README.md @@ -0,0 +1,118 @@ +--- +owner: "@esengine" +backup: "@SivanCola" +status: active +reviewed: 2026-10-02 +--- + +# Packaged repository orientation profile + +This native v2 package contributes one manually invoked subagent profile, +`map`. It reads selected local files in an isolated child conversation and +returns an answer to the caller. It adds no hook, MCP server, extension runtime, +install script or production dependency. + +1. The flat `agents/map.md` declares a description, a read-only tool ceiling + and the method. Reasonix loads declared agent roots as manual subagent + profiles; the qualified invocation is `/repo-map-kit:agent:map`. +2. The file omits a model override, so the normal host model selection applies. + Use a configured model that supports tool calls. Live invocation uses that + provider and its normal credentials and usage charges. + +## Install and discover + +From the Reasonix repository root: + +```sh +reasonix plugin install ./examples/repo-map-kit --dry-run +reasonix plugin install ./examples/repo-map-kit --yes +reasonix plugin doctor repo-map-kit +reasonix plugin show repo-map-kit +reasonix subagent list +``` + +1. Preview must leave the package uninstalled. Installation copies the whole + package into the configured Reasonix home. +2. `subagent list` must include `repo-map-kit:agent:map` as manual and read-only. + `doctor` checks package paths; it does not verify an answer's quality. +3. Start a new session after changing package installation or enablement. + Use the qualified invocation below in an interactive session. Packaged + profiles are edited in their source package, rather than rewritten by the + project/global profile editor. + +## Run the bounded exercise + +The fixture is source to inspect, not a command to execute. It needs no compiler +for this exercise. From the repository root, invoke the read-only runner: + +```sh +reasonix subagent try repo-map-kit:agent:map --dir ./examples/repo-map-kit/fixture \ + "Explain LoadLines in loader.go, compare it with NOTES.md, and report what you did not verify." +``` + +`run` also honors this profile's `read-only: true`: + +```sh +reasonix subagent run repo-map-kit:agent:map --dir ./examples/repo-map-kit/fixture \ + "Explain LoadLines in loader.go, compare it with NOTES.md, and report what you did not verify." +``` + +In an interactive session rooted at the fixture, use: + +```text +/repo-map-kit:agent:map Explain LoadLines in loader.go, compare it with NOTES.md, and report what you did not verify. +``` + +1. Check that the answer explains newline splitting, whitespace trimming, + blank-line removal and order preservation, citing `loader.go`. +2. `NOTES.md` deliberately claims blank lines and whitespace are preserved. + A successful answer cites that note and the contradictory source. +3. The answer must list inspected files, say tests were not run and leave both + files unchanged. This is repository orientation, not a review verdict. +4. Run a failure case asking for `missing.go`, which is absent. The answer must + identify the missing input and avoid inventing its implementation. +5. The profile's tool ceiling excludes shell, writer and recursive delegation + tools. The host also supplies its `complete_subtask` reporting protocol. + Normal host access rules still govern readable files; naming a task directory + is guidance, not a new per-profile filesystem access boundary. + +## Edit, disable and remove + +Edit the source profile, preview replacement, then apply it and start a new +session. Editing the source does not change an installed copy. + +```sh +reasonix plugin install ./examples/repo-map-kit --replace --dry-run +reasonix plugin install ./examples/repo-map-kit --replace --yes +reasonix plugin disable repo-map-kit +reasonix plugin enable repo-map-kit +reasonix plugin remove repo-map-kit --yes +``` + +1. Disable preserves the copy but excludes the profile from newly built + sessions. Enable restores it. Removal deletes the managed copy and registration. +2. For link-based authoring, remove the copy, preview with `--link --dry-run`, + then apply with `--link --yes`. Keep the linked source available; removing + the link registration keeps its external source directory. + +## Verify the host path + +```sh +go test ./internal/assembly/boot/ -run '^TestEffectRepoMapExampleLifecycle$' -count=1 +``` + +1. The test copies this package through the real approved-plan installer, + removes the temporary source and builds real controllers. It checks + discovery, child prompt/task isolation, the read-only tool schema and actual + fixture reads reaching the provider across install, disable, enable and removal. +2. The recording provider is scripted; this proves the host path, not that a + live model correctly explains the exercise. The manual acceptance steps + above are still needed for the selected model. No public market submission + is claimed. + +| Observation | Check | +| --- | --- | +| No profile in a fresh session | Confirm package enablement and `contributes.agents`; run `subagent list` in the target workspace. | +| A profile cannot be selected | Use the exact qualified name from `subagent list`, `/repo-map-kit:agent:map`. | +| An edit has no effect | Replace the copy or use a link, then build a new session. | +| The answer claims a build or edit | Check tool results; this profile has only reader tools. | diff --git a/examples/repo-map-kit/agents/map.md b/examples/repo-map-kit/agents/map.md new file mode 100644 index 0000000000..30d0f833f6 --- /dev/null +++ b/examples/repo-map-kit/agents/map.md @@ -0,0 +1,28 @@ +--- +owner: "@esengine" +backup: "@SivanCola" +status: active +reviewed: 2026-10-02 +name: map +description: Trace a bounded repository question to local files and report gaps +read-only: true +tools: [Read, Grep, Glob, LS] +--- + +Trace the task's repository question through the selected local files. + +1. Establish the question and the files or directory named in the task. If an + essential input is missing or unreadable, identify it and limit the answer + to the evidence available. Do not invent a replacement input. +2. Read the selected definitions and callers, using targeted searches as needed. + For the fixture, inspect `loader.go` and `NOTES.md` directly. Do not survey + unrelated directories. +3. Explain the path from input to output. Cite file paths and line numbers for + each behavior claim. If documentation disagrees with source, cite both and + describe the disagreement without changing either file. +4. Return three short sections: Findings, Sources inspected, and Not verified. + Name the inspected scope. Report missing files, untraced paths and checks + not run; an empty search is not proof that a behavior is absent everywhere. +5. Keep the answer in the caller's requested language. Read-only tools cannot + apply a fix or run a build in this profile. Do not claim test results, + permission decisions, a completed review or an implemented change. diff --git a/examples/repo-map-kit/fixture/NOTES.md b/examples/repo-map-kit/fixture/NOTES.md new file mode 100644 index 0000000000..72adfa0b86 --- /dev/null +++ b/examples/repo-map-kit/fixture/NOTES.md @@ -0,0 +1,14 @@ +--- +owner: "@esengine" +backup: "@SivanCola" +status: active +reviewed: 2026-10-02 +--- + +# Loader notes + +LoadLines preserves blank lines and leading or trailing whitespace. + +This sentence is deliberately stale exercise material. Compare it with +`loader.go`; report the disagreement and cite the relevant source lines. +The fixture has no tests and provides no runtime verification evidence. diff --git a/examples/repo-map-kit/fixture/go.mod b/examples/repo-map-kit/fixture/go.mod new file mode 100644 index 0000000000..a6729ef270 --- /dev/null +++ b/examples/repo-map-kit/fixture/go.mod @@ -0,0 +1,3 @@ +module example.invalid/repo-map-fixture + +go 1.22 diff --git a/examples/repo-map-kit/fixture/loader.go b/examples/repo-map-kit/fixture/loader.go new file mode 100644 index 0000000000..46baccd6d4 --- /dev/null +++ b/examples/repo-map-kit/fixture/loader.go @@ -0,0 +1,13 @@ +package fixture + +import "strings" + +func LoadLines(text string) []string { + var lines []string + for _, line := range strings.Split(text, "\n") { + if line = strings.TrimSpace(line); line != "" { + lines = append(lines, line) + } + } + return lines +} diff --git a/examples/repo-map-kit/reasonix-plugin.json b/examples/repo-map-kit/reasonix-plugin.json new file mode 100644 index 0000000000..6394c3d8df --- /dev/null +++ b/examples/repo-map-kit/reasonix-plugin.json @@ -0,0 +1,9 @@ +{ + "apiVersion": "reasonix.io/plugin/v2", + "name": "repo-map-kit", + "version": "1.0.0", + "description": "A read-only repository orientation profile with a local exercise", + "contributes": { + "agents": ["agents"] + } +} diff --git a/internal/assembly/boot/effect_repo_map_example_test.go b/internal/assembly/boot/effect_repo_map_example_test.go new file mode 100644 index 0000000000..b06366d14c --- /dev/null +++ b/internal/assembly/boot/effect_repo_map_example_test.go @@ -0,0 +1,262 @@ +package boot + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "reasonix/internal/base/frontmatter" + "reasonix/internal/contract/event" + "reasonix/internal/contract/provider" + "reasonix/internal/ext/installsource" + "reasonix/internal/ext/pluginpkg" + "reasonix/internal/ext/skill" +) + +const repoMapPromptStart = "Trace the task's repository question through the selected local files." + +type repoMapExampleProvider struct { + effectRecordingProvider + files []string +} + +func (*repoMapExampleProvider) Name() string { return "boot-repo-map-example" } + +func (p *repoMapExampleProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) { + p.mu.Lock() + p.reqs = append(p.reqs, req) + p.mu.Unlock() + ch := make(chan provider.Chunk, len(p.files)+1) + if !strings.Contains(systemMessage(req.Messages), repoMapPromptStart) { + ch <- provider.Chunk{Type: provider.ChunkText, Text: "parent acknowledged"} + } else { + results := 0 + for _, message := range req.Messages { + if message.Role == provider.RoleTool && strings.HasPrefix(message.ToolCallID, "fixture-") { + results++ + } + } + if results == len(p.files) { + ch <- provider.Chunk{Type: provider.ChunkText, Text: "fixture inspected"} + } else { + for i, file := range p.files { + args, err := json.Marshal(map[string]string{"path": file}) + if err != nil { + return nil, err + } + ch <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: fmt.Sprintf("fixture-%d", i), Name: "read_file", Arguments: string(args)}} + } + } + } + ch <- provider.Chunk{Type: provider.ChunkDone} + close(ch) + return ch, nil +} + +func TestEffectRepoMapExampleLifecycle(t *testing.T) { + example, err := filepath.Abs(filepath.Join("..", "..", "..", "examples", "repo-map-kit")) + if err != nil { + t.Fatal(err) + } + source := robustTempDir(t) + if err := os.CopyFS(source, os.DirFS(example)); err != nil { + t.Fatal(err) + } + rawProfile, err := os.ReadFile(filepath.Join(source, "agents", "map.md")) + if err != nil { + t.Fatal(err) + } + _, profileBody := frontmatter.SplitLegacy(string(rawProfile)) + profileBody = strings.TrimSpace(profileBody) + home := isolateConfigHome(t) + reasonixHome := filepath.Join(home, ".reasonix") + t.Setenv("REASONIX_HOME", reasonixHome) + workspace := robustTempDir(t) + if err := os.CopyFS(workspace, os.DirFS(filepath.Join(source, "fixture"))); err != nil { + t.Fatal(err) + } + files := []string{"loader.go", "NOTES.md"} + contents := make([]string, len(files)) + for i, file := range files { + body, err := os.ReadFile(filepath.Join(workspace, file)) + if err != nil { + t.Fatal(err) + } + contents[i] = string(body) + } + t.Chdir(workspace) + writeFile(t, workspace, "reasonix.toml", ` +default_model = "test-model" +[agent] +system_prompt = "REPO MAP PARENT BASE" +[environment] +enabled = false +[codegraph] +enabled = false +[[providers]] +name = "test-model" +kind = "boot-repo-map-example" +model = "x" +`) + approveWorkspace(t, workspace) + var rec *repoMapExampleProvider + provider.Register("boot-repo-map-example", func(provider.Config) (provider.Provider, error) { return rec, nil }) + installer := installsource.NewTool(installsource.Options{ProjectRoot: workspace, HomeDir: home, RequireApprovedPlan: true}) + type result struct { + OK bool `json:"ok"` + Status string `json:"status"` + Applied bool `json:"applied"` + PlanID string `json:"planId"` + } + install := func(args map[string]any) result { + t.Helper() + raw, err := json.Marshal(args) + if err != nil { + t.Fatal(err) + } + out, err := installer.Execute(t.Context(), raw) + if err != nil { + t.Fatal(err) + } + var got result + if err := json.Unmarshal([]byte(out), &got); err != nil || !got.OK { + t.Fatalf("install_source = %s, err=%v", out, err) + } + return got + } + var prefix, schema string + check := func(t *testing.T, present bool) { + t.Helper() + rec = &repoMapExampleProvider{files: files} + ctrl, err := Build(t.Context(), Options{Sink: event.Discard, SessionDir: filepath.Join(robustTempDir(t), "sessions")}) + if err != nil { + t.Fatal(err) + } + defer ctrl.Close() + const name = "repo-map-kit:agent:map" + var profile skill.Skill + found := false + for _, sk := range ctrl.SlashSkills() { + if sk.SlashName() == name { + profile, found = sk, true + } + } + if found != present { + t.Fatalf("qualified profile found=%t, want %t", found, present) + } + if present && (profile.Plugin != "repo-map-kit" || profile.RunAs != skill.RunSubagent || profile.Invocation != "manual" || !profile.ReadOnly || profile.Model != "" || profile.Body != profileBody || !slices.Equal(profile.AllowedTools, []string{"read_file", "grep", "glob", "ls"})) { + t.Fatalf("installed profile = %+v", profile) + } + if present && profile.Path != filepath.Join(pluginpkg.InstallRoot(reasonixHome, "repo-map-kit"), "agents", "map.md") { + t.Fatalf("profile is not the managed copy: %s", profile.Path) + } + ctrl.EnsureSessionPath() + const parentOnly = "PARENT-ONLY-CONTEXT: examine an unrelated question" + if err := ctrl.Run(t.Context(), parentOnly); err != nil { + t.Fatal(err) + } + parent := rec.requests()[0] + encoded, err := json.Marshal(parent.Tools) + if err != nil { + t.Fatal(err) + } + if prefix == "" { + prefix, schema = systemMessage(parent.Messages), string(encoded) + } else if prefix != systemMessage(parent.Messages) || schema != string(encoded) { + t.Fatal("package lifecycle changed the parent prefix or tool schema") + } + if strings.Contains(systemMessage(parent.Messages), profileBody) { + t.Fatal("manual child profile entered the parent prefix") + } + const task = "Explain loader.go and compare NOTES.md; report what was not verified." + answer, err := ctrl.RunSubagentProfile(t.Context(), name, task, false) + if !present { + if err == nil || len(rec.requests()) != 1 { + t.Fatalf("absent profile invoked a provider: answer=%q, err=%v", answer, err) + } + return + } + if err != nil || !strings.HasSuffix(answer, "fixture inspected") { + t.Fatalf("child answer = %q, err=%v", answer, err) + } + requests := rec.requests() + if len(requests) != 3 { + t.Fatalf("provider requests=%d, want parent + child read + child result", len(requests)) + } + for _, req := range requests[1:] { + if !strings.HasPrefix(systemMessage(req.Messages), profileBody) { + t.Fatal("installed profile body did not reach the child") + } + if !slices.Equal(toolSchemaNames(req.Tools), []string{"complete_subtask", "glob", "grep", "ls", "read_file"}) { + t.Fatalf("child tool ceiling = %v", toolSchemaNames(req.Tools)) + } + var user string + for _, message := range req.Messages { + if strings.Contains(message.Content, parentOnly) { + t.Fatal("child received unrelated parent conversation") + } + if message.Role == provider.RoleUser { + user = message.Content + } + } + if !strings.Contains(user, task) { + t.Fatalf("child did not receive the explicit task: %s", user) + } + } + for i, file := range files { + var toolResult string + for _, message := range requests[2].Messages { + if message.Role == provider.RoleTool && message.ToolCallID == fmt.Sprintf("fixture-%d", i) { + toolResult = message.Content + } + } + for line := range strings.SplitSeq(contents[i], "\n") { + if line = strings.TrimSpace(line); line != "" && !strings.Contains(toolResult, line) { + t.Errorf("fixture %s line missing from provider tool result: %q", file, line) + } + } + if got, err := os.ReadFile(filepath.Join(workspace, file)); err != nil || string(got) != contents[i] { + t.Fatalf("fixture %s changed: %q, err=%v", file, got, err) + } + } + } + t.Run("absent", func(t *testing.T) { check(t, false) }) + args := map[string]any{"source": source, "kind": "plugin", "mode": "copy", "scope": "global"} + preview := install(args) + if preview.Applied || preview.Status != "planned" || preview.PlanID == "" { + t.Fatalf("preview = %+v", preview) + } + t.Run("preview", func(t *testing.T) { check(t, false) }) + args["apply"], args["planId"] = true, preview.PlanID + if applied := install(args); !applied.Applied || applied.Status != "done" { + t.Fatalf("applied = %+v", applied) + } + if err := os.RemoveAll(source); err != nil { + t.Fatal(err) + } + t.Run("copied", func(t *testing.T) { check(t, true) }) + if err := pluginpkg.SetEnabled(reasonixHome, "repo-map-kit", false); err != nil { + t.Fatal(err) + } + if got, err := os.ReadFile(filepath.Join(pluginpkg.InstallRoot(reasonixHome, "repo-map-kit"), "agents", "map.md")); err != nil || string(got) != string(rawProfile) { + t.Fatalf("disable changed the copied profile: %q, err=%v", got, err) + } + t.Run("disabled", func(t *testing.T) { check(t, false) }) + if err := pluginpkg.SetEnabled(reasonixHome, "repo-map-kit", true); err != nil { + t.Fatal(err) + } + t.Run("reenabled", func(t *testing.T) { check(t, true) }) + if removed := install(map[string]any{"op": "uninstall", "name": "repo-map-kit", "scope": "global"}); !removed.Applied || removed.Status != "done" { + t.Fatalf("removed = %+v", removed) + } + if _, err := os.Stat(pluginpkg.InstallRoot(reasonixHome, "repo-map-kit")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("managed profile survives removal: %v", err) + } + t.Run("removed", func(t *testing.T) { check(t, false) }) +}