docs: add a packaged read-only subagent exercise

This commit is contained in:
xiexian committed 2026-10-02 13:39:32 +08:00
1 parent 88ca5ae82b
commit e309ee2200
8 files changed
+449

No files matched your search

+2
View File
@@ -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).
+118
View File
@@ -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. |
+28
View File
@@ -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.
+14
View File
@@ -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.
+3
View File
@@ -0,0 +1,3 @@
module example.invalid/repo-map-fixture
go 1.22
+13
View File
@@ -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
}
@@ -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"]
}
}
@@ -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) })
}