feat: add plan builtin toolset for shared multi-agent collaboration - #3227
Conversation
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟡 NEEDS ATTENTION
Three medium-severity issues found in the new plan toolset — all in pkg/tools/builtin/plan/plan.go.
| continue | ||
| } | ||
| summaries = append(summaries, Summary{ | ||
| Name: plan.Name, |
There was a problem hiding this comment.
[MEDIUM] list_plans returns names from JSON content — name/file mismatch goes undetected
listPlans populates Summary.Name from the plan.Name field stored inside the JSON file rather than deriving it from the filename:
summaries = append(summaries, Summary{
Name: plan.Name, // from JSON — not from entry.Name()
...
})Under normal API usage the write path always sets plan.Name = params.Name before saving to <name>.json, so they stay in sync. But if a plan file is ever manually edited or otherwise corrupted so the stored name field differs from the filename, list_plans will return the wrong name. Any agent that then calls read_plan or delete_plan with the name it just received from the list will resolve planPath(name) to a different filename and get "plan not found".
Suggested fix: derive the name from the directory entry instead:
Name: strings.TrimSuffix(entry.Name(), ".json"),This makes list_plans self-consistent with the actual storage key regardless of what is in the JSON body.
| // sharedToolSet returns the one ToolSet shared by every agent in this process, | ||
| // built once on first use. Sharing a single instance means all collaborating | ||
| // agents serialize their plan operations on the same mutex. | ||
| var sharedToolSet = sync.OnceValues(func() (*ToolSet, error) { |
There was a problem hiding this comment.
[MEDIUM] sync.OnceValues permanently memoizes a MkdirAll failure — transient startup error permanently disables the plan toolset
var sharedToolSet = sync.OnceValues(func() (*ToolSet, error) {
dir := DefaultDir()
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("failed to create plans directory: %w", err)
}
return New(dir), nil
})sync.OnceValues memoises both the value and the error on first call. If os.MkdirAll fails for any reason (e.g., a parent directory not yet mounted, a transient permission hiccup), every subsequent call to CreateToolSet() for the lifetime of the process will return the same error — the directory can be created moments later and it will still be reported as unavailable.
save() already calls os.MkdirAll independently on every write, which shows the pattern is viable outside OnceValues. Consider removing the MkdirAll from the OnceValues initializer and relying solely on save's call, so a late-arriving directory is recovered from automatically. Alternatively, split construction of the ToolSet struct (which cannot fail and is safe to memoize) from the directory-creation side-effect:
var sharedToolSet = sync.OnceValue(func() *ToolSet {
return New(DefaultDir())
})
func CreateToolSet() (tools.ToolSet, error) {
return sharedToolSet(), nil
}| if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { | ||
| continue | ||
| } | ||
| plan, ok, err := t.load(filepath.Join(t.dir, entry.Name())) |
There was a problem hiding this comment.
[MEDIUM] list_plans silently drops unreadable/corrupt entries — caller cannot distinguish "no plans" from "some plans failed to load"
plan, ok, err := t.load(filepath.Join(t.dir, entry.Name()))
if err != nil || !ok {
// Skip unreadable or corrupt files so one bad plan doesn't break
// listing the rest; read_plan surfaces the specific error.
continue
}The comment documents the intent, but the result is that a caller — including an LLM agent — receives a list that silently omits entries. In a multi-agent collaboration scenario the agent may conclude a plan doesn't exist, write a new one under the same name, and overwrite whatever was there, when the real situation was that the plan was temporarily unreadable.
A lightweight improvement that preserves the "don't abort the whole list on one bad file" goal while giving callers visibility:
type ListPlansResult struct {
Plans []Summary `json:"plans"`
Warnings []string `json:"warnings,omitempty"`
}For each failed entry, append fmt.Sprintf("skipped %q: %v", entry.Name(), err) to Warnings. Callers that don't care can ignore the field; agents that check can surface it to a human.
Add a 'plan' builtin toolset that lets multiple agents collaborate on named plans stored in a global shared folder under the docker-agent data directory. Exposes write_plan, read_plan, list_plans, and delete_plan. Wires the toolset into the default registry and adds the 'plan' type to the agent config JSON schema. Assisted-By: claude-sonnet-4-5
- Share one ToolSet per process (singleton) so all collaborating agents serialize plan operations on a single mutex instead of each agent holding its own, which previously allowed lost updates. - Write plans atomically (temp file + rename) via pkg/atomicfile so readers never observe a partial file and an existing symlink at the target is replaced rather than followed. - Validate plan names strictly against a slug pattern instead of lossy sanitization, eliminating silent collisions (e.g. 'a/b' and 'a b' mapping onto 'a-b') and path traversal. - Distinguish 'not found' from corrupt/unreadable plan files: read_plan and list surface real errors instead of masking them, list skips corrupt files, and delete_plan removes corrupt plans too. - Preserve author (like title) across revisions when omitted. Assisted-By: claude-sonnet-4-5
a22d9a1 to
068fbfb
Compare
- list_plans now derives each plan's name from its filename rather than the name field stored inside the JSON, keeping list output consistent with the storage key that read_plan/delete_plan use. - list_plans surfaces unreadable/corrupt entries as warnings (new ListResult output type) instead of silently dropping them, so an agent can't mistake a temporarily unreadable plan for a missing one and clobber it. - CreateToolSet no longer memoizes a directory-creation failure via sync.OnceValues: it builds the (infallible) ToolSet struct once with sync.OnceValue and relies on save()'s own MkdirAll, so a late-arriving plans directory is recovered from automatically. Assisted-By: claude-sonnet-4-5
|
Thanks for the review! Addressed all three issues in c4d898b:
Added/updated tests cover all three (including a name-from-filename test and a warnings-surfaced test). |
Multi-agent workflows have no standard way for agents to hand information to one another across turns. This change introduces a
planbuiltin toolset that gives agents a shared, persistent scratchpad: named plans stored under the docker-agent data directory (paths.GetDataDir()/plans/). Any agent in a process can read or write a plan by name, so a planner agent can sketch work and executor agents can consume it without any custom tool wiring.The toolset exposes four tools —
write_plan,read_plan,list_plans, anddelete_plan— backed by a singleton instance so all agents serialize on one mutex. Writes are atomic: the implementation usespkg/atomicfile(temp file + rename) so readers never observe partial content, and an existing symlink at the target path is replaced rather than followed. Plan names are validated against a strict lowercase slug pattern (letters, digits,-,_) so silent name collisions and path traversal are structurally impossible rather than mitigated by sanitization.loaddistinguishes "not found" from genuinely unreadable files:read_plansurfaces real I/O errors,list_plansskips corrupt entries while still reporting them, anddelete_plancan remove a corrupt plan to recover from a bad state.The toolset is wired into the default registry in
pkg/teamloader/toolsetsand the"plan"type is added toagent-schema.json. An example inexamples/shared_plan.yamlshows two agents collaborating on a shared plan. No existing toolset or agent config is affected.