Skip to content
5 changes: 4 additions & 1 deletion cmd/late/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,9 @@ func main() {
mainTools[k] = v
}
mainTools["write_implementation_plan"] = true
mainTools["create_todos"] = true
mainTools["list_todos"] = true
mainTools["finish_todo"] = true
mainTools["write_file"] = false
mainTools["target_edit"] = false

Expand Down Expand Up @@ -281,7 +284,7 @@ func main() {

// Create root orchestrator
// We'll add middlewares later once the program is started
rootAgent := orchestrator.NewBaseOrchestrator("main", sess, nil, 0)
rootAgent := orchestrator.NewBaseOrchestrator(common.MainAgentID, sess, nil, 0)

model := tui.NewModel(rootAgent, renderer, appConfig)
model.ApplyOrchestratorModel = func(setting appconfig.ModelSetting) tea.Cmd {
Expand Down
9 changes: 8 additions & 1 deletion internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ func NewSubagentOrchestrator(
for _, t := range parent.Registry().All() {
// Skip spawn_subagent and write_implementation_plan to prevent recursion/confusion
name := t.Name()
if name == "spawn_subagent" || name == "write_implementation_plan" {
if name == "spawn_subagent" || name == "write_implementation_plan" ||
name == "create_todos" || name == "list_todos" || name == "finish_todo" {
continue
}
sess.Registry.Register(t)
Expand All @@ -82,6 +83,12 @@ func NewSubagentOrchestrator(
subagentTools[t] = true
}
}

// Todo tools are orchestrator-only: never register them for subagents,
// even if a subagent config lists them in allowed_tools.
for _, name := range []string{"create_todos", "list_todos", "finish_todo"} {
delete(subagentTools, name)
}
executor.RegisterTools(sess.Registry, subagentTools)

// 3. Construct Initial Context
Expand Down
53 changes: 53 additions & 0 deletions internal/agent/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,56 @@ func TestNewSubagentOrchestratorID(t *testing.T) {
t.Errorf("Expected child ID to contain 'coder', got %s", child.ID())
}
}

// TestSubagentRegistryHasNoTodoTools verifies that a spawned subagent NEVER
// receives todo tools in its registry, even when enabledTools includes them.
func TestSubagentRegistryHasNoTodoTools(t *testing.T) {
cfg := client.Config{BaseURL: "http://localhost:8080"}
c := client.NewClient(cfg)

mockSession := session.New(c, "/tmp/mock-session.json", []client.ChatMessage{}, "mock system prompt", true)
parent := orchestrator.NewBaseOrchestrator("parent", mockSession, nil, 100)

enabledTools := map[string]bool{
"read_file": true,
"create_todos": true,
"list_todos": true,
"finish_todo": true,
}

child, err := NewSubagentOrchestrator(
c,
"test goal",
[]string{},
"coder",
enabledTools,
false,
false,
100,
parent,
nil,
)
if err != nil {
t.Fatalf("Failed to create subagent orchestrator: %v", err)
}

childBase, ok := child.(*orchestrator.BaseOrchestrator)
if !ok {
t.Fatalf("Expected BaseOrchestrator, got %T", child)
}

sess := childBase.Session()

if sess.Registry.Get("create_todos") != nil {
t.Fatalf("expected subagent registry to NOT contain create_todos")
}
if sess.Registry.Get("list_todos") != nil {
t.Fatalf("expected subagent registry to NOT contain list_todos")
}
if sess.Registry.Get("finish_todo") != nil {
t.Fatalf("expected subagent registry to NOT contain finish_todo")
}
if sess.Registry.Get("read_file") == nil {
t.Fatalf("expected subagent registry to contain read_file")
}
}
8 changes: 6 additions & 2 deletions internal/assets/prompts/instruction-orchestrator.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Your goal is to analyze complex user requests, explore the existing codebase to
* **YOU MUST NOT**: Conduct broad, initial codebase exploration yourself. You must delegate this to the `researcher` subagent to conserve your context window.
* **YOU MUST**: Use the `search_tool` instead of using the `bash_tool` with e.g. `grep`/`find`/`rg` to search for and match patterns and strings in the codebase.
* **YOU MUST**: Use `write_implementation_plan` to record your design before any execution.
* **YOU MUST**: Use `create_todos`, `list_todos`, and `finish_todo` to track high-level execution progress, but ONLY AFTER writing the implementation plan.
* **YOU MUST**: Use `spawn_subagent` (type `coder`) for **ALL** direct file modifications. **CRITICAL TOOL RULE: You MUST invoke the `spawn_subagent` tool MULTIPLE TIMES—exactly once for EVERY individual step in your Implementation Plan. You are strictly forbidden from passing multiple steps or the entire plan into a single `spawn_subagent` call.**
* **YOU CANNOT**: Edit files, create files (other than the plan), or run destructive bash commands.
* *Note: Direct file-editing tools (like `write_file` or `target_edit`) are physically removed from your toolset. You MUST delegate all coding to subagents.*
Expand Down Expand Up @@ -46,8 +47,9 @@ Before generating the final output, you must internally simulate the execution o

Output a structured **Implementation Plan** in Markdown. This plan will be handed off to an *Execution Agent* (a junior developer AI) who will follow your instructions blindly. Clarity and precision are paramount.

**You MUST use the `write_implementation_plan` tool to save your plan to `${{CWD}}/implementation_plan.md`.**
Your final response to the user should confirm the plan is written and ask for approval.
1. **Write the Plan**: You MUST use the `write_implementation_plan` tool to save your plan to `${{CWD}}/implementation_plan.md`.
2. **Initialize Todo Tracking**: Immediately AFTER saving the implementation plan, you MUST call `create_todos` with a high-level list of steps that track the major phases/milestones of your implementation plan. Do NOT call `create_todos` before the implementation plan is written.
3. **Request Approval**: Your final response to the user should confirm the plan is written, todos are created, and ask for approval.

### Phase 5: Skill Activation & Knowledge Transfer

Expand Down Expand Up @@ -96,6 +98,8 @@ Clarity is key. Group steps logically.

You must not edit any files yourself. You must use `coder` subagents to edit files. You must use `spawn_subagent` to spawn a subagent. You must use atomic steps in your plan. Each step should be a single, atomic action that can be performed independently of other steps. Each `coder` subagent being invoked by you must implement one single step only of your plan.

* **Progress Tracking**: Before or after spawning subagents, use `list_todos` to review progress. As each high-level step or milestone from your plan is completed by a `coder` subagent, use `finish_todo` to mark it complete.

## Current working dir

Your current working directory is `${{CWD}}`
Expand Down
4 changes: 4 additions & 0 deletions internal/common/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ const (
ToolApprovalKey contextKey = "tool_approval"
)

// MainAgentID is the orchestrator ID of the root/main agent.
// Subagents use IDs of the form "<type>-subagent-<n>".
const MainAgentID = "main"

// GetInputProvider returns the InputProvider from the context.
func GetInputProvider(ctx context.Context) InputProvider {
if p, ok := ctx.Value(InputProviderKey).(InputProvider); ok {
Expand Down
17 changes: 17 additions & 0 deletions internal/common/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,20 @@ func (r *ToolRegistry) All() []Tool {
})
return all
}

// TodoItem represents a single item in a todo list.
type TodoItem struct {
Text string
Done bool
}

// TodoProvider provides a read-only snapshot of current todos.
type TodoProvider interface {
GetTodos() []TodoItem
}

// ConversationResetter clears tool state that must not carry into a new
// conversation.
type ConversationResetter interface {
ResetConversationState()
}
3 changes: 3 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ func defaultConfig() Config {
"spawn_subagent": true,
"bash": true,
"search_tool": true,
"create_todos": true,
"list_todos": true,
"finish_todo": true,
},
}
}
Expand Down
17 changes: 17 additions & 0 deletions internal/executor/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"context"
"encoding/json"
"fmt"
"sync"

"late/internal/client"
"late/internal/common"
"late/internal/pathutil"
Expand Down Expand Up @@ -139,6 +141,21 @@ func RegisterTools(reg *tool.Registry, enabledTools map[string]bool) {
reg.Register(tool.NewTargetEditTool())
}

// Register Todo planning tools (orchestrator-only, not inherited by subagents)
if enabledTools["create_todos"] || enabledTools["list_todos"] || enabledTools["finish_todo"] {
var todos []tool.Todo
var mu sync.Mutex
if enabledTools["create_todos"] {
reg.Register(tool.CreateTodosTool{Todos: &todos, Mu: &mu})
}
if enabledTools["list_todos"] {
reg.Register(tool.ListTodosTool{Todos: &todos, Mu: &mu})
}
if enabledTools["finish_todo"] {
reg.Register(tool.FinishTodoTool{Todos: &todos, Mu: &mu})
}
}

// Register Skills
skillDirs := []string{}
if userSkillsDir, err := pathutil.LateSkillsDir(); err == nil {
Expand Down
29 changes: 29 additions & 0 deletions internal/executor/executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -345,3 +345,32 @@ func TestRegisterTools_Planning(t *testing.T) {
t.Error("bash should be registered in planning mode")
}
}

func TestRegisterTools_TodoTools(t *testing.T) {
c := client.NewClient(client.Config{BaseURL: "http://localhost:0"})
histPath := filepath.Join(t.TempDir(), "history.json")
sess := session.New(c, histPath, nil, "", false)

enabledTools := map[string]bool{
"create_todos": true,
"list_todos": true,
"finish_todo": true,
}
RegisterTools(sess.Registry, enabledTools)

for _, name := range []string{"create_todos", "list_todos", "finish_todo"} {
if sess.Registry.Get(name) == nil {
t.Errorf("expected tool '%s' to be registered", name)
}
}

// Verify disabled tools are not registered
sessDisabled := session.New(c, histPath, nil, "", false)
RegisterTools(sessDisabled.Registry, map[string]bool{})
for _, name := range []string{"create_todos", "list_todos", "finish_todo"} {
if sessDisabled.Registry.Get(name) != nil {
t.Errorf("expected tool '%s' not to be registered when disabled", name)
}
}
}

10 changes: 9 additions & 1 deletion internal/orchestrator/base.go
Original file line number Diff line number Diff line change
Expand Up @@ -450,7 +450,15 @@ func (o *BaseOrchestrator) Parent() common.Orchestrator {
func (o *BaseOrchestrator) Reset() error {
o.mu.Lock()
defer o.mu.Unlock()
return o.sess.StartNewConversation()
if err := o.sess.StartNewConversation(); err != nil {
return err
}
for _, registeredTool := range o.sess.Registry.All() {
if resetter, ok := registeredTool.(common.ConversationResetter); ok {
resetter.ResetConversationState()
}
}
return nil
}

func (o *BaseOrchestrator) Rewind(index int) error {
Expand Down
16 changes: 16 additions & 0 deletions internal/orchestrator/base_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ package orchestrator

import (
"context"
"encoding/json"
"late/internal/client"
"late/internal/common"
"late/internal/session"
"late/internal/tool"
"os"
"path/filepath"
"sync"
"testing"
)

Expand Down Expand Up @@ -93,6 +96,16 @@ func TestBaseOrchestrator_ResetStartsNewConversation(t *testing.T) {
}

sess := session.New(nil, originalPath, history, "", false)
var todos []tool.Todo
var todoMu sync.Mutex
createTodos := tool.CreateTodosTool{Todos: &todos, Mu: &todoMu}
listTodos := tool.ListTodosTool{Todos: &todos, Mu: &todoMu}
sess.Registry.Register(createTodos)
sess.Registry.Register(listTodos)
ctx := context.WithValue(context.Background(), common.OrchestratorIDKey, common.MainAgentID)
if _, err := createTodos.Execute(ctx, json.RawMessage(`{"todos":["old conversation task"]}`)); err != nil {
t.Fatalf("creating todo: %v", err)
}
o := NewBaseOrchestrator("test-orch", sess, nil, 10)
if err := o.Reset(); err != nil {
t.Fatalf("Reset() error = %v", err)
Expand All @@ -108,6 +121,9 @@ func TestBaseOrchestrator_ResetStartsNewConversation(t *testing.T) {
if len(o.History()) != 0 {
t.Fatalf("new conversation history length = %d, want 0", len(o.History()))
}
if got := listTodos.GetTodos(); len(got) != 0 {
t.Fatalf("new conversation retained todos: %#v", got)
}
if sess.HistoryPath == originalPath {
t.Fatal("new conversation reused the original history path")
}
Expand Down
Loading