Skip to content

feat: add plan builtin toolset for shared multi-agent collaboration - #3227

Merged
dgageot merged 3 commits into
docker:mainfrom
dgageot:feat/plan-toolset
Jun 25, 2026
Merged

feat: add plan builtin toolset for shared multi-agent collaboration#3227
dgageot merged 3 commits into
docker:mainfrom
dgageot:feat/plan-toolset

Conversation

@dgageot

@dgageot dgageot commented Jun 25, 2026

Copy link
Copy Markdown
Member

Multi-agent workflows have no standard way for agents to hand information to one another across turns. This change introduces a plan builtin 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, and delete_plan — backed by a singleton instance so all agents serialize on one mutex. Writes are atomic: the implementation uses pkg/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. load distinguishes "not found" from genuinely unreadable files: read_plan surfaces real I/O errors, list_plans skips corrupt entries while still reporting them, and delete_plan can remove a corrupt plan to recover from a bad state.

The toolset is wired into the default registry in pkg/teamloader/toolsets and the "plan" type is added to agent-schema.json. An example in examples/shared_plan.yaml shows two agents collaborating on a shared plan. No existing toolset or agent config is affected.

@dgageot
dgageot requested a review from a team as a code owner June 25, 2026 12:47

@docker-agent docker-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assessment: 🟡 NEEDS ATTENTION

Three medium-severity issues found in the new plan toolset — all in pkg/tools/builtin/plan/plan.go.

Comment thread pkg/tools/builtin/plan/plan.go Outdated
continue
}
summaries = append(summaries, Summary{
Name: plan.Name,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment thread pkg/tools/builtin/plan/plan.go Outdated
// 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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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()))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

dgageot added 2 commits June 25, 2026 14:59
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
@dgageot
dgageot force-pushed the feat/plan-toolset branch from a22d9a1 to 068fbfb Compare June 25, 2026 12:59
trungutt
trungutt previously approved these changes Jun 25, 2026
- 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
@dgageot

dgageot commented Jun 25, 2026

Copy link
Copy Markdown
Member Author

Thanks for the review! Addressed all three issues in c4d898b:

  1. list_plans name/file mismatchlist_plans now derives each plan's name from its filename (strings.TrimSuffix(entry.Name(), ".json")) instead of the name field inside the JSON, so the returned name always matches the storage key that read_plan/delete_plan resolve against.

  2. sync.OnceValues memoizing MkdirAll failure — switched to sync.OnceValue that only builds the (infallible) ToolSet struct. Directory creation is left to save()'s own os.MkdirAll on each write, so a transient/late-arriving plans directory is recovered from automatically instead of permanently disabling the toolset.

  3. list_plans silently dropping corrupt entrieslist_plans now returns a ListResult with a warnings field; unreadable/corrupt files are reported there (e.g. skipped "foo": ...) rather than being omitted, so an agent won't mistake a temporarily unreadable plan for a missing one and overwrite it.

Added/updated tests cover all three (including a name-from-filename test and a warnings-surfaced test). golangci-lint and the full test suite pass.

@aheritier aheritier added area/agent For work that has to do with the general agent loop/agentic features of the app area/tools For features/issues/fixes related to the usage of built-in and MCP tools kind/feat PR adds a new feature (maps to feat:). Use on PRs only. labels Jun 25, 2026
@dgageot
dgageot merged commit 771a951 into docker:main Jun 25, 2026
5 checks passed
@dgageot
dgageot deleted the feat/plan-toolset branch June 25, 2026 13:39
pull Bot pushed a commit to TheTechOddBug/cagent that referenced this pull request Jun 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/agent For work that has to do with the general agent loop/agentic features of the app area/tools For features/issues/fixes related to the usage of built-in and MCP tools kind/feat PR adds a new feature (maps to feat:). Use on PRs only.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants