diff --git a/agent-schema.json b/agent-schema.json index e30adc2be0..3a17634732 100644 --- a/agent-schema.json +++ b/agent-schema.json @@ -2152,7 +2152,6 @@ "background_jobs", "tasks", "plan", - "session_plan", "session_context", "todo", "fetch", @@ -2494,7 +2493,6 @@ "background_jobs", "tasks", "plan", - "session_plan", "session_context", "todo", "fetch", diff --git a/docs/concepts/tools/index.md b/docs/concepts/tools/index.md index 149e876d9a..0a01187435 100644 --- a/docs/concepts/tools/index.md +++ b/docs/concepts/tools/index.md @@ -53,7 +53,6 @@ Docker Agent ships with several built-in tools that require no external dependen | [Scheduler](../../tools/scheduler/index.md) | Schedule instructions to run at a time or on a recurring interval | | [Webhook](../../tools/webhook/index.md) | Outbound notifications to Slack, Discord, Telegram, IFTTT, and more | | [Plan](../../tools/plan/index.md) | Shared persistent scratchpad for multi-agent collaboration | -| [Session Plan](../../tools/session_plan/index.md) | Per-session plan tracker for the draft/review/execute workflow | | [Session Context](../../tools/session_context/index.md) | Reference a previous session as context | ## MCP Tools diff --git a/docs/configuration/tools/index.md b/docs/configuration/tools/index.md index d25e36968e..27cc0316ba 100644 --- a/docs/configuration/tools/index.md +++ b/docs/configuration/tools/index.md @@ -26,7 +26,6 @@ Built-in tools are included with Docker Agent and require no external dependenci | `environment` | Report the OS and resolved shell (read-only, no arguments, auto-approved) | [Environment](../../tools/environment/index.md) | | `think` | Reasoning scratchpad | [Think](../../tools/think/index.md) | | `plan` | Shared persistent scratchpad for multi-agent collaboration | [Plan](../../tools/plan/index.md) | -| `session_plan` | Per-session markdown plan for the draft-review-execute workflow | [Session Plan](../../tools/session_plan/index.md) | | `session_context` | Reference a previous session as context (read-only) | [Session Context](../../tools/session_context/index.md) | | `todo` | Task list management | [Todo](../../tools/todo/index.md) | | `memory` | Persistent key-value storage (SQLite) | [Memory](../../tools/memory/index.md) | diff --git a/docs/data/nav.yml b/docs/data/nav.yml index 5577526b8e..88e362a4db 100644 --- a/docs/data/nav.yml +++ b/docs/data/nav.yml @@ -103,8 +103,6 @@ url: /tools/think/ - title: Plan url: /tools/plan/ - - title: Session Plan - url: /tools/session_plan/ - title: Todo url: /tools/todo/ - title: Tasks diff --git a/docs/features/cli/index.md b/docs/features/cli/index.md index 4eab494462..2db1260f95 100644 --- a/docs/features/cli/index.md +++ b/docs/features/cli/index.md @@ -723,7 +723,7 @@ $ docker agent plans get --session # a session's plan $ docker agent plans export --session --output ./session-plan.md ``` -Plans live under the data directory (`~/.cagent/plans/` and `~/.cagent/session_plans/` by default), so `--data-dir` selects which store the commands operate on. +Plans live under the data directory (`~/.cagent/plans/` by default), so `--data-dir` selects which store the commands operate on. ### `docker agent debug` diff --git a/docs/features/tui/index.md b/docs/features/tui/index.md index a439dfbe6e..dbb24c2656 100644 --- a/docs/features/tui/index.md +++ b/docs/features/tui/index.md @@ -81,7 +81,7 @@ Type `/` during a session to see available commands, or press Ctrl+`, or `/effort` alone to pick from the supported levels; reasoning models only). Press Tab after `/effort` and a space to complete a level the current model supports | | `/settings` | Manage appearance, behavior, and notification preferences | diff --git a/docs/tools/plan/index.md b/docs/tools/plan/index.md index 3f27b01989..49184be69f 100644 --- a/docs/tools/plan/index.md +++ b/docs/tools/plan/index.md @@ -142,7 +142,7 @@ $ docker agent plans update release --file ./plan.md --expected-version 1 ### The `/plans` browser in the TUI -Inside the full-screen TUI, the `/plans` slash command (also in the Ctrl+K command palette) opens a plan browser over the same store the agents use, so changes made by agents mid-session appear immediately. The list shows every shared plan plus the current session's [session plan](../session_plan/index.md), with each plan's scope, identity (name, or session ID for the session plan), status, version (`-` for the unversioned session plan), last update time, and title. +Inside the full-screen TUI, the `/plans` slash command (also in the Ctrl+K command palette) opens a plan browser over the same store the agents use, so changes made by agents mid-session appear immediately. The list shows every shared plan with its identity (name), status, version, last update time, and title. Keybindings: @@ -151,7 +151,7 @@ Keybindings: | ↑/↓, mouse | Navigate; Enter or double-click opens a detail view with the full metadata and scrollable markdown content | | / | Filter by name, title, status, or scope (Esc leaves filter mode) | | r | Refresh from storage | -| x | Export the selected plan to `.md` (shared) or `session-plan-.md` (session) in the session's working directory. An existing file is never overwritten — the export fails with a notification instead | +| x | Export the selected plan to `.md` in the session's working directory. An existing file is never overwritten — the export fails with a notification instead | | s | Set a shared plan's free-form status via a small input dialog | | e | Edit a shared plan's content in `$VISUAL`/`$EDITOR` | | n | Create a new shared plan: pick a name, then draft the content in `$VISUAL`/`$EDITOR` (an empty draft aborts) | diff --git a/docs/tools/session_plan/index.md b/docs/tools/session_plan/index.md deleted file mode 100644 index ea4c8e4970..0000000000 --- a/docs/tools/session_plan/index.md +++ /dev/null @@ -1,134 +0,0 @@ ---- -title: "Session Plan Tool" -description: "Per-session plan tracker for the draft, review, execute workflow." -keywords: docker agent, ai agents, tools, toolsets, session plan tool -linkTitle: "Session Plan" -weight: 160 -canonical: https://docs.docker.com/ai/docker-agent/tools/session_plan/ ---- - -_Per-session plan tracker for the "draft, review, execute" workflow._ - -## Overview - -The `session_plan` toolset gives one agent a place to write a plan for the current session, signal that the plan is ready, and let the host route the next turn to an executing agent. - -Different from the [`plan` toolset](../plan/index.md) — `plan` is for shared, named plans multiple agents collaborate on over many sessions. `session_plan` is for one ephemeral plan per session, scoped to that session by ID. - -Plans live as Markdown files under: - -```text -~/.cagent/session_plans/.md -``` - -The tool surface is three tools: - -| Tool | Description | -| -------------------- | ---------------------------------------------------------------------------------------------------- | -| `write_session_plan` | Create or replace this session's plan as markdown. There's exactly one plan per session. | -| `read_session_plan` | Read the plan written for the current session and return it as markdown. | -| `exit_plan_mode` | Signal that the plan is ready for review. Does not switch agents on its own. | - -## Configuration - -```yaml -toolsets: - - type: session_plan -``` - -No configuration options. The plan path is derived from the session ID; the agent does not name plans. - -Restrict the toolset to a subset of tools the standard way: - -```yaml -# An agent that consumes a plan but should not be able to (re)write or finalize one. -toolsets: - - type: session_plan - tools: - - read_session_plan -``` - -## When to call exit_plan_mode - -Call `exit_plan_mode` once the plan is complete and you do not intend to change it on the next turn. It validates that a plan exists for the session and returns a "ready for review" tool result. It does **not** switch agents or solicit user approval on its own — the host application owns the next-turn routing (for example, by reading the tool result, by a UI affordance the user toggles, or by a `handoff` declared on the agent). - -This separation keeps the tool reusable across UIs: a CLI that prints tool results inline, a chat UI with a plan-mode toggle, and a server that auto-routes the next turn through a `handoff` can all consume the same signal without one stepping on another. - -## Storage and cleanup - -- Plans are markdown files written atomically (temp + rename), so concurrent readers — in this process or another — never observe a partial write. -- A best-effort sweep on first use of the toolset removes plan files older than 30 days under the plans directory. Stranded plans for long-gone sessions do not accumulate. -- The session ID identifies the file directly. There is no in-process mutex or revision counter, because two sessions cannot map to the same path. - -## Events - -A `session_plan_updated` event is emitted whenever `write_session_plan` succeeds: - -```json -{ - "type": "session_plan_updated", - "session_id": "...", - "path": "/Users/.../.cagent/session_plans/.md", - "content": "# my plan\n...", - "agent_name": "planner" -} -``` - -Embedders that render the plan inline can subscribe and update without re-reading the file. - -## Managing session plans from the host - -A session plan belongs to its session: hosts can read and export it, never change it. - -- **CLI** — the [`docker agent plans`](../../features/cli/index.md#docker-agent-plans) command group lists, reads (`get --session `), and exports session plans alongside shared plans. Mutations (`update`, `status`, `delete`) are refused with an `unsupported` error explaining the ownership rule. -- **TUI** — the `/plans` browser (see the [plan toolset docs](../plan/index.md#the-plans-browser-in-the-tui) for the full keybinding table) includes the **current session's** plan as the `session` scope row; plans of other sessions are never enumerated. Its identity is the session ID and its version column shows `-` — session plans have no versions. Enter opens the detail view (scope, session ID, update time, scrollable markdown) and x exports to `session-plan-.md` in the working directory (refusing to overwrite an existing file). e opens the plan body in your external editor (`$VISUAL` or `$EDITOR`) for editing — the write is unguarded and last-write-wins by design. Status and delete visibly report that session plans don't support them (session plans belong to their session and carry no shared-plan metadata). The browser refreshes live on the `session_plan_updated` event, so a plan the agent just wrote appears without reopening. - -## Example - -A two-agent workflow: `root` executes, `planner` plans. `/plan` hands off to the planner; `exit_plan_mode` signals "ready", and the host decides what happens next. - -```yaml -agents: - root: - model: anthropic/claude-sonnet-4-5 - description: Executes approved plans - instruction: | - You execute plans the planner has handed off. When you see a message - that a plan has been approved, read it with read_session_plan and work - through its steps in order. - toolsets: - - type: session_plan - tools: - - read_session_plan - - type: filesystem - - type: shell - commands: - plan: - description: "Switch to the planner" - agent: planner - - planner: - model: anthropic/claude-sonnet-4-5 - description: Investigates and writes plans for review - instruction: | - Investigate the user's request, then write the plan with - write_session_plan. Iterate with the user until the plan is complete, - then call exit_plan_mode to mark it ready for review. - toolsets: - - type: session_plan - - type: filesystem - readonly: true - - type: user_prompt -``` - -See [`examples/session_plan.yaml`](https://github.com/docker/docker-agent/blob/main/examples/session_plan.yaml) for a complete working example. - -## Error Handling - -- `read_session_plan` and `exit_plan_mode` return a "no plan written yet" error when called before `write_session_plan`. -- `write_session_plan` validates the session ID and refuses to write anything that could escape the plans directory; in practice the runtime generates UUIDs so this only triggers if an embedder supplies a hand-crafted ID. - -> [!TIP] -> **session_plan vs. plan vs. todo vs. tasks** -> -> Use **session_plan** when one agent drafts an approach for the user to review before another agent executes it (ephemeral, one per session). Use [plan](../plan/index.md) for shared, named plans multiple agents collaborate on over many sessions. Use [todo](../todo/index.md) for lightweight in-session task lists. Use [tasks](../tasks/index.md) for a structured, persistent task database with priorities and dependencies. diff --git a/examples/session_plan.yaml b/examples/session_plan.yaml deleted file mode 100644 index 309ea2b5e0..0000000000 --- a/examples/session_plan.yaml +++ /dev/null @@ -1,48 +0,0 @@ -# A two-agent "plan then execute" workflow. -# -# The user starts in `root`. Typing `/plan` switches to `planner`, which has -# read-only tools and the session_plan toolset. The planner investigates -# the request, writes the plan to /session_plans/.md, -# and calls exit_plan_mode when ready — that signal tells the host the plan -# is final. Switching back to `root` for execution is up to the host -# (toggle, slash command, an explicit handoff on the agent, …); the tool -# itself does not move the conversation. - -agents: - root: - model: anthropic/claude-sonnet-4-5 - description: Executes plans the planner has handed off - instruction: | - You execute plans the planner has prepared. When you see a message - saying a plan has been approved, read it with read_session_plan and - work through its steps in order. For ambiguous or multi-file changes - that have not been planned yet, type `/plan ` first instead of - starting work directly. - toolsets: - - type: session_plan - tools: - - read_session_plan - - type: filesystem - - type: shell - commands: - plan: - description: "Switch to the planner" - agent: planner - - planner: - model: anthropic/claude-sonnet-4-5 - description: Investigates the request and writes a plan for user review - instruction: | - Investigate the user's request with read-only tools, then draft the - plan with write_session_plan. Iterate — re-read code, ask clarifying - questions with user_prompt — until the plan is complete. When the - plan is final, call exit_plan_mode to mark it ready for review. - - Do not modify any files. The only write you may perform is - write_session_plan. - toolsets: - - type: session_plan - - type: filesystem - readonly: true - - type: fetch - - type: user_prompt diff --git a/pkg/runtime/client.go b/pkg/runtime/client.go index 9d5449a3b5..6d831a1c0b 100644 --- a/pkg/runtime/client.go +++ b/pkg/runtime/client.go @@ -88,7 +88,6 @@ func NewClient(baseURL string, opts ...ClientOption) (*Client, error) { "stream_started": func() Event { return &StreamStartedEvent{} }, "shell": func() Event { return &ShellOutputEvent{} }, "session_title": func() Event { return &SessionTitleEvent{} }, - "session_plan_updated": func() Event { return &SessionPlanUpdatedEvent{} }, "plan_changed": func() Event { return &PlanChangedEvent{} }, "session_summary": func() Event { return &SessionSummaryEvent{} }, "session_compaction": func() Event { return &SessionCompactionEvent{} }, diff --git a/pkg/runtime/event.go b/pkg/runtime/event.go index 3c50ce01c1..5f557591b5 100644 --- a/pkg/runtime/event.go +++ b/pkg/runtime/event.go @@ -445,29 +445,6 @@ func SessionTitle(sessionID, title string) Event { func (e *SessionTitleEvent) GetSessionID() string { return e.SessionID } -// SessionPlanUpdatedEvent fires when the session_plan toolset writes a plan. -// Content and Path let a UI render the plan inline without re-reading the file. -type SessionPlanUpdatedEvent struct { - AgentContext - - Type string `json:"type"` - SessionID string `json:"session_id"` - Content string `json:"content,omitempty"` - Path string `json:"path,omitempty"` -} - -func SessionPlanUpdated(sessionID, content, path, agentName string) Event { - return &SessionPlanUpdatedEvent{ - Type: "session_plan_updated", - SessionID: sessionID, - Content: content, - Path: path, - AgentContext: newAgentContext(agentName), - } -} - -func (e *SessionPlanUpdatedEvent) GetSessionID() string { return e.SessionID } - // PlanChangedEvent fires after an agent successfully mutates a shared plan // (write, status change, or delete) through the plan toolset. It carries // identity and version only — no content — so a UI refreshes through its own diff --git a/pkg/runtime/loop.go b/pkg/runtime/loop.go index 50dbe00443..3124534668 100644 --- a/pkg/runtime/loop.go +++ b/pkg/runtime/loop.go @@ -33,7 +33,6 @@ import ( "github.com/docker/docker-agent/pkg/tools/builtin/modelpicker" "github.com/docker/docker-agent/pkg/tools/builtin/plan" "github.com/docker/docker-agent/pkg/tools/builtin/sessioncontext" - "github.com/docker/docker-agent/pkg/tools/builtin/sessionplan" "github.com/docker/docker-agent/pkg/tools/builtin/skills" "github.com/docker/docker-agent/pkg/tools/builtin/transfertask" "github.com/docker/docker-agent/pkg/userconfig" @@ -47,9 +46,6 @@ func (r *LocalRuntime) registerDefaultTools() { r.toolMap[modelpicker.ToolNameChangeModel] = r.handleChangeModel r.toolMap[modelpicker.ToolNameRevertModel] = r.handleRevertModel r.toolMap[skills.ToolNameRunSkill] = r.handleRunSkill - r.toolMap[sessionplan.ToolNameWriteSessionPlan] = r.handleWriteSessionPlan - r.toolMap[sessionplan.ToolNameReadSessionPlan] = r.handleReadSessionPlan - r.toolMap[sessionplan.ToolNameExitPlanMode] = r.handleExitPlanMode r.toolMap[sessioncontext.ToolNameListSessions] = r.handleListSessions r.toolMap[sessioncontext.ToolNameReadSession] = r.handleReadSession diff --git a/pkg/runtime/sessionplan_handlers.go b/pkg/runtime/sessionplan_handlers.go deleted file mode 100644 index b5ed3c87d8..0000000000 --- a/pkg/runtime/sessionplan_handlers.go +++ /dev/null @@ -1,66 +0,0 @@ -package runtime - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "strings" - - "github.com/docker/docker-agent/pkg/session" - "github.com/docker/docker-agent/pkg/tools" - "github.com/docker/docker-agent/pkg/tools/builtin/sessionplan" -) - -func (r *LocalRuntime) handleWriteSessionPlan(ctx context.Context, sess *session.Session, toolCall tools.ToolCall, events EventSink, _ tools.Runtime) (*tools.ToolCallResult, error) { - var args sessionplan.WriteSessionPlanArgs - if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil { - return nil, fmt.Errorf("invalid arguments: %w", err) - } - if strings.TrimSpace(args.Content) == "" { - return tools.ResultError("content must not be empty"), nil - } - - path, err := sessionplan.WriteContent(sessionplan.DefaultDir(), sess.ID, args.Content) - if err != nil { - if errors.Is(err, sessionplan.ErrInvalidSessionID) { - return tools.ResultError(err.Error()), nil - } - return nil, err - } - - events.Emit(SessionPlanUpdated(sess.ID, args.Content, path, r.CurrentAgentName(ctx))) - return tools.ResultSuccess("Plan saved to " + path), nil -} - -func (r *LocalRuntime) handleReadSessionPlan(_ context.Context, sess *session.Session, _ tools.ToolCall, _ EventSink, _ tools.Runtime) (*tools.ToolCallResult, error) { - content, _, err := sessionplan.ReadContent(sessionplan.DefaultDir(), sess.ID) - if errors.Is(err, sessionplan.ErrPlanNotFound) { - return tools.ResultError("no plan written yet for this session; call write_session_plan first"), nil - } - if err != nil { - if errors.Is(err, sessionplan.ErrInvalidSessionID) { - return tools.ResultError(err.Error()), nil - } - return nil, err - } - return tools.ResultSuccess(content), nil -} - -// handleExitPlanMode marks the session's plan as ready and returns control to -// the host. Switching agents is the host's decision — the runtime does not -// call setCurrentAgent here so a CLI that prints results inline, a chat UI -// with a mode toggle, and a server with a configured handoff can all consume -// the same marker without one stepping on the other. -func (r *LocalRuntime) handleExitPlanMode(_ context.Context, sess *session.Session, _ tools.ToolCall, _ EventSink, _ tools.Runtime) (*tools.ToolCallResult, error) { - if _, _, err := sessionplan.ReadContent(sessionplan.DefaultDir(), sess.ID); err != nil { - if errors.Is(err, sessionplan.ErrPlanNotFound) { - return tools.ResultError("no plan to mark ready; call write_session_plan before exit_plan_mode"), nil - } - if errors.Is(err, sessionplan.ErrInvalidSessionID) { - return tools.ResultError(err.Error()), nil - } - return nil, err - } - return tools.ResultSuccess("Plan ready for review."), nil -} diff --git a/pkg/teamloader/toolsets/catalog.go b/pkg/teamloader/toolsets/catalog.go index 87f7ba686d..9fc7a7e600 100644 --- a/pkg/teamloader/toolsets/catalog.go +++ b/pkg/teamloader/toolsets/catalog.go @@ -30,7 +30,6 @@ var BuiltinToolsets = []BuiltinToolsetInfo{ builtinToolset("scheduler", "scheduler", "Schedule instructions to run at a time or on a recurring interval"), builtinToolset("script", "script", "Define custom shell scripts as named tools with typed parameters"), builtinToolset("session_context", "session_context", "Reference a previous session as context in the current one"), - builtinToolset("session_plan", "session_plan", "Per-session plan tracker for the draft/review/execute workflow"), builtinToolset("shell", "shell", "Execute shell commands in the user's environment"), builtinToolset("tasks", "tasks", "Persistent task database with priorities and dependencies"), builtinToolset("think", "think", "Step-by-step reasoning scratchpad for planning"), diff --git a/pkg/teamloader/toolsets/toolsets.go b/pkg/teamloader/toolsets/toolsets.go index 60fa3a8ebe..901007d468 100644 --- a/pkg/teamloader/toolsets/toolsets.go +++ b/pkg/teamloader/toolsets/toolsets.go @@ -25,7 +25,6 @@ import ( "github.com/docker/docker-agent/pkg/tools/builtin/rag" "github.com/docker/docker-agent/pkg/tools/builtin/scheduler" "github.com/docker/docker-agent/pkg/tools/builtin/sessioncontext" - "github.com/docker/docker-agent/pkg/tools/builtin/sessionplan" "github.com/docker/docker-agent/pkg/tools/builtin/shell" "github.com/docker/docker-agent/pkg/tools/builtin/tasks" "github.com/docker/docker-agent/pkg/tools/builtin/think" @@ -64,7 +63,6 @@ func DefaultToolsetCreators() map[string]teamloader.ToolsetCreator { "scheduler": teamloader.Creator(scheduler.CreateToolSet), "script": shell.ScriptCreator, "session_context": teamloader.Creator(sessioncontext.CreateToolSet), - "session_plan": teamloader.Creator(sessionplan.CreateToolSet), "shell": shell.Creator, "tasks": tasks.Creator, "think": teamloader.Creator(think.CreateToolSet), diff --git a/pkg/tools/builtin/sessionplan/sessionplan.go b/pkg/tools/builtin/sessionplan/sessionplan.go deleted file mode 100644 index 154275a141..0000000000 --- a/pkg/tools/builtin/sessionplan/sessionplan.go +++ /dev/null @@ -1,202 +0,0 @@ -// Package sessionplan provides a per-session plan tracker for the -// "draft, review, execute" workflow. One markdown plan per session, stored -// at /session_plans/.md. The toolset is a metadata -// stub — the runtime owns the handlers (pkg/runtime/sessionplan_handlers.go) -// so they can reach the live session. -// -// Complementary to pkg/tools/builtin/plan, which models shared, named plans -// multiple agents collaborate on. Tool names are deliberately distinct -// (write_session_plan / read_session_plan vs. write_plan / read_plan) so -// the two toolsets can coexist on the same agent without colliding. -package sessionplan - -import ( - "bytes" - "context" - "errors" - "fmt" - "io/fs" - "log/slog" - "os" - "path/filepath" - "regexp" - "sync" - "time" - - "github.com/docker/docker-agent/pkg/atomicfile" - "github.com/docker/docker-agent/pkg/paths" - "github.com/docker/docker-agent/pkg/tools" -) - -const ( - ToolNameWriteSessionPlan = "write_session_plan" - ToolNameReadSessionPlan = "read_session_plan" - ToolNameExitPlanMode = "exit_plan_mode" -) - -// sessionIDPattern rejects anything that could escape the plans directory -// ('/', '\\', '..'). 128 chars is well above any realistic ID. -var sessionIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`) - -const maxPlanAge = 30 * 24 * time.Hour - -var ( - ErrInvalidSessionID = errors.New("invalid session ID") - ErrPlanNotFound = errors.New("session plan not found") -) - -func DefaultDir() string { - return filepath.Join(paths.GetDataDir(), "session_plans") -} - -type ToolSet struct{} - -var ( - _ tools.ToolSet = (*ToolSet)(nil) - _ tools.Instructable = (*ToolSet)(nil) -) - -func CreateToolSet() (tools.ToolSet, error) { - runStartupSweep() - return &ToolSet{}, nil -} - -// New builds a toolset without running the startup sweep, for tests and -// embedders that want predictable filesystem behaviour. -func New() *ToolSet { - return &ToolSet{} -} - -func (t *ToolSet) Instructions() string { - return `## Session Plan Tools - -Use this toolset to draft a plan for the current session, then mark it ready for review. - -- ` + "`write_session_plan(content)`" + ` creates or replaces the plan for this session. There is exactly one plan per session and ` + "`content`" + ` is the full markdown — calling it again replaces the previous version. -- ` + "`read_session_plan()`" + ` returns the plan you (or an earlier turn) wrote. It errors when no plan has been written yet. -- ` + "`exit_plan_mode()`" + ` signals that the plan is ready for review. Call this once the plan is complete and you do not intend to change it on the next turn. It does not switch agents on its own — the host application decides what happens next based on the user's reply.` -} - -type WriteSessionPlanArgs struct { - Content string `json:"content" jsonschema:"The full plan content as markdown. Replaces the existing plan for this session."` -} - -// Tools advertises the metadata only; Handler is intentionally nil so the -// runtime's toolMap takes over (same pattern as handoff and transfer_task). -func (t *ToolSet) Tools(context.Context) ([]tools.Tool, error) { - return []tools.Tool{ - { - Name: ToolNameWriteSessionPlan, - Category: "session_plan", - Description: "Create or replace the plan for this session as markdown. There is exactly one plan per session, addressed by session ID.", - Parameters: tools.MustSchemaFor[WriteSessionPlanArgs](), - Annotations: tools.ToolAnnotations{ - Title: "Write Session Plan", - }, - }, - { - Name: ToolNameReadSessionPlan, - Category: "session_plan", - Description: "Read the plan written for this session and return it as markdown. Errors if no plan has been written yet.", - Annotations: tools.ToolAnnotations{ - Title: "Read Session Plan", - ReadOnlyHint: true, - }, - }, - { - Name: ToolNameExitPlanMode, - Category: "session_plan", - Description: "Signal that the plan written for this session is ready for review. Only call this once the plan is complete and you do not intend to change it on the next turn. It does not switch agents.", - Annotations: tools.ToolAnnotations{ - Title: "Exit Plan Mode", - ReadOnlyHint: true, - }, - }, - }, nil -} - -func Path(dir, sessionID string) (string, error) { - if !sessionIDPattern.MatchString(sessionID) { - return "", fmt.Errorf("%w: %q", ErrInvalidSessionID, sessionID) - } - return filepath.Join(dir, sessionID+".md"), nil -} - -// WriteContent uses atomicfile.Write so a concurrent reader — in this process -// or another — sees either the old or the new file, never a partial one, and -// so an existing symlink is replaced rather than followed. -func WriteContent(dir, sessionID, content string) (string, error) { - path, err := Path(dir, sessionID) - if err != nil { - return "", err - } - if err := os.MkdirAll(dir, 0o700); err != nil { - return "", fmt.Errorf("create plans dir: %w", err) - } - if err := atomicfile.Write(path, bytes.NewReader([]byte(content)), 0o600); err != nil { - return "", fmt.Errorf("write plan: %w", err) - } - return path, nil -} - -// ReadContent returns ErrPlanNotFound (with the path so callers can include -// it in user-facing messages) on ENOENT, distinguishing "plan missing" from a -// real read failure such as a permission error. -func ReadContent(dir, sessionID string) (content, path string, err error) { - path, err = Path(dir, sessionID) - if err != nil { - return "", "", err - } - data, err := os.ReadFile(path) - if errors.Is(err, fs.ErrNotExist) { - return "", path, ErrPlanNotFound - } - if err != nil { - return "", path, fmt.Errorf("read plan: %w", err) - } - return string(data), path, nil -} - -// Sweep is best-effort: a permission glitch on one file should not block -// cleaning the rest, but the first error encountered is returned so callers -// can surface it. -func Sweep(dir string, now time.Time, maxAge time.Duration) error { - entries, err := os.ReadDir(dir) - if errors.Is(err, fs.ErrNotExist) { - return nil - } - if err != nil { - return fmt.Errorf("scan plans dir: %w", err) - } - cutoff := now.Add(-maxAge) - var firstErr error - for _, e := range entries { - if e.IsDir() || filepath.Ext(e.Name()) != ".md" { - continue - } - info, err := e.Info() - if err != nil { - if firstErr == nil { - firstErr = err - } - continue - } - if !info.ModTime().Before(cutoff) { - continue - } - if err := os.Remove(filepath.Join(dir, e.Name())); err != nil && !errors.Is(err, fs.ErrNotExist) { - if firstErr == nil { - firstErr = err - } - } - } - return firstErr -} - -// runStartupSweep swallows the sweep error so a read-only data dir does not -// block the toolset from being created. -var runStartupSweep = sync.OnceFunc(func() { - if err := Sweep(DefaultDir(), time.Now(), maxPlanAge); err != nil { - slog.Warn("sessionplan: sweep of stale plan files failed", "error", err, "dir", DefaultDir()) - } -}) diff --git a/pkg/tools/builtin/sessionplan/sessionplan_test.go b/pkg/tools/builtin/sessionplan/sessionplan_test.go deleted file mode 100644 index c859955e17..0000000000 --- a/pkg/tools/builtin/sessionplan/sessionplan_test.go +++ /dev/null @@ -1,160 +0,0 @@ -package sessionplan - -import ( - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestPath(t *testing.T) { - t.Parallel() - t.Run("accepts UUID-shaped IDs", func(t *testing.T) { - dir := t.TempDir() - path, err := Path(dir, "7c2d8f0a-1234-4abc-9def-1234567890ab") - require.NoError(t, err) - assert.Equal(t, filepath.Join(dir, "7c2d8f0a-1234-4abc-9def-1234567890ab.md"), path) - }) - - // Path-traversal defence: the regex is the only thing standing between an - // adversarial session ID and arbitrary disk writes. - for _, name := range []string{ - "", - "../escape", - "a/b", - `a\b`, - "-leading-dash", - "_leading-underscore", - ".leading-dot", - strings.Repeat("a", 200), - } { - t.Run("rejects "+name, func(t *testing.T) { - _, err := Path("/plans", name) - require.Error(t, err) - assert.ErrorIs(t, err, ErrInvalidSessionID) - }) - } -} - -func TestWriteReadRoundTrip(t *testing.T) { - t.Parallel() - dir := t.TempDir() - - path, err := WriteContent(dir, "session-1", "# my plan\nstep 1\n") - require.NoError(t, err) - assert.Equal(t, filepath.Join(dir, "session-1.md"), path) - - got, gotPath, err := ReadContent(dir, "session-1") - require.NoError(t, err) - assert.Equal(t, path, gotPath) - assert.Equal(t, "# my plan\nstep 1\n", got) -} - -func TestWriteContentOverwrites(t *testing.T) { - t.Parallel() - dir := t.TempDir() - - _, err := WriteContent(dir, "s", "v1") - require.NoError(t, err) - _, err = WriteContent(dir, "s", "v2") - require.NoError(t, err) - - got, _, err := ReadContent(dir, "s") - require.NoError(t, err) - assert.Equal(t, "v2", got) -} - -func TestWriteContentCreatesDir(t *testing.T) { - t.Parallel() - base := t.TempDir() - dir := filepath.Join(base, "nested", "plans") - - _, err := WriteContent(dir, "s", "hello") - require.NoError(t, err) - - info, err := os.Stat(dir) - require.NoError(t, err) - assert.True(t, info.IsDir()) -} - -func TestReadContentNotFound(t *testing.T) { - t.Parallel() - dir := t.TempDir() - - content, path, err := ReadContent(dir, "ghost") - assert.Empty(t, content) - assert.Equal(t, filepath.Join(dir, "ghost.md"), path) - assert.ErrorIs(t, err, ErrPlanNotFound) -} - -func TestReadContentInvalidSessionID(t *testing.T) { - t.Parallel() - _, _, err := ReadContent(t.TempDir(), "../escape") - assert.ErrorIs(t, err, ErrInvalidSessionID) -} - -func TestSweepRemovesOldPlans(t *testing.T) { - t.Parallel() - dir := t.TempDir() - now := time.Now() - - makeFile := func(name string, mtime time.Time) string { - path := filepath.Join(dir, name) - require.NoError(t, os.WriteFile(path, []byte("x"), 0o600)) - require.NoError(t, os.Chtimes(path, mtime, mtime)) - return path - } - - fresh := makeFile("fresh.md", now.Add(-time.Hour)) - stale := makeFile("stale.md", now.Add(-maxPlanAge-time.Hour)) - // Sweep targets *.md only so we don't poke at unrelated files a future - // version of the toolset might drop in the dir. - other := makeFile("notes.txt", now.Add(-maxPlanAge*2)) - subdir := filepath.Join(dir, "subdir") - require.NoError(t, os.Mkdir(subdir, 0o700)) - - require.NoError(t, Sweep(dir, now, maxPlanAge)) - - _, err := os.Stat(stale) - require.ErrorIs(t, err, os.ErrNotExist, "stale plan should have been removed") - - _, err = os.Stat(fresh) - require.NoError(t, err, "fresh plan should survive") - - _, err = os.Stat(other) - require.NoError(t, err, "non-md file should survive") - - _, err = os.Stat(subdir) - require.NoError(t, err, "subdir should survive") -} - -func TestSweepMissingDirIsNoOp(t *testing.T) { - t.Parallel() - err := Sweep(filepath.Join(t.TempDir(), "does-not-exist"), time.Now(), maxPlanAge) - assert.NoError(t, err) -} - -func TestTools(t *testing.T) { - t.Parallel() - ts := New() - got, err := ts.Tools(t.Context()) - require.NoError(t, err) - - names := make([]string, 0, len(got)) - for _, tool := range got { - names = append(names, tool.Name) - // Handlers must be nil so the runtime's toolMap takes precedence; a - // non-nil handler here would silently bypass the runtime path. - assert.Nil(t, tool.Handler, "tool %q should not declare a handler", tool.Name) - } - assert.ElementsMatch(t, []string{ToolNameWriteSessionPlan, ToolNameReadSessionPlan, ToolNameExitPlanMode}, names) -} - -func TestInstructionsNonEmpty(t *testing.T) { - t.Parallel() - assert.NotEmpty(t, (&ToolSet{}).Instructions()) -}