Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
5159846
feat: add tasks tool with dependency management
silvin-lubecki Jan 24, 2026
11add00
feat: add tests and TUI support for tasks tool
silvin-lubecki Jan 24, 2026
ad708d7
chore: use tasks tool in golang_developer agent
silvin-lubecki Jan 24, 2026
67ff0c2
fix: address lint issues in tasks tool
silvin-lubecki Jan 24, 2026
26b45aa
fix(tui): show blocker descriptions instead of IDs in tasks sidebar
silvin-lubecki Jan 25, 2026
7633b5d
feat(tasks): add cross-session persistence
silvin-lubecki Jan 25, 2026
50f8999
feat(tasks): always persist with auto-detected list ID from git repo
silvin-lubecki Jan 26, 2026
5e86780
test(tasks): add tests for worktree and cross-repo task list IDs
silvin-lubecki Jan 26, 2026
2aa86d1
docs: add tasks tool documentation
silvin-lubecki Jan 26, 2026
0be7092
fix(tasks): add handler-level mutex to prevent race conditions
silvin-lubecki Jan 26, 2026
1b1c06e
fix(tasks): add circular dependency check in batch creation and save …
silvin-lubecki Jan 26, 2026
b2f3c6b
fix(tasks): fail fast on load error to prevent data loss
silvin-lubecki Jan 26, 2026
2b66cc6
docs: add tasks tool guidance to golang_developer agent prompt
silvin-lubecki Jan 26, 2026
33e5e79
refactor(tasks): simplify to always-shared singleton with file persis…
silvin-lubecki Jan 26, 2026
3455cd2
docs: update tasks tool documentation for always-shared behavior
silvin-lubecki Jan 26, 2026
e4a2b57
refactor(tasks): replace singleton with dependency injection via regi…
silvin-lubecki Jan 26, 2026
7a96bbb
fix(tasks): address PR review comments
silvin-lubecki Jan 26, 2026
e66f981
fix(fake): prevent panic in StreamCopy when client disconnects
silvin-lubecki Jan 26, 2026
0c9bef0
docs(tasks): clarify task ID format in tool descriptions
silvin-lubecki Jan 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions cmd/root/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@ import (
const (
flagModelsGateway = "models-gateway"
envModelsGateway = "CAGENT_MODELS_GATEWAY"
flagTaskList = "task-list"
envTaskListID = "CAGENT_TASK_LIST_ID"
)

func addRuntimeConfigFlags(cmd *cobra.Command, runConfig *config.RuntimeConfig) {
addGatewayFlags(cmd, runConfig)
addTaskListFlags(cmd, runConfig)
cmd.PersistentFlags().StringSliceVar(&runConfig.EnvFiles, "env-from-file", nil, "Set environment variables from file")
cmd.PersistentFlags().BoolVar(&runConfig.GlobalCodeMode, "code-mode-tools", false, "Provide a single tool to call other tools via Javascript")
cmd.PersistentFlags().StringVar(&runConfig.WorkingDir, "working-dir", "", "Set the working directory for the session (applies to tools and relative paths)")
Expand Down Expand Up @@ -94,3 +97,22 @@ func addGatewayFlags(cmd *cobra.Command, runConfig *config.RuntimeConfig) {
return nil
}
}

func addTaskListFlags(cmd *cobra.Command, runConfig *config.RuntimeConfig) {
cmd.PersistentFlags().StringVar(&runConfig.TaskListID, flagTaskList, "", "Use a persistent task list with the given ID")

persistentPreRunE := cmd.PersistentPreRunE
cmd.PersistentPreRunE = func(c *cobra.Command, args []string) error {
// Precedence: CLI flag > environment variable
if runConfig.TaskListID != "" {
logFlagShadowing(os.Getenv(envTaskListID), envTaskListID, flagTaskList)
} else if taskListID := os.Getenv(envTaskListID); taskListID != "" {
runConfig.TaskListID = taskListID
}

if persistentPreRunE != nil {
return persistentPreRunE(c, args)
}
return nil
}
}
50 changes: 50 additions & 0 deletions cmd/root/flags_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,53 @@ func TestCanonize(t *testing.T) {
})
}
}

func TestTaskListLogic(t *testing.T) {
tests := []struct {
name string
env string
args []string
expected string
}{
{
name: "empty_by_default",
expected: "",
},
{
name: "from_env",
env: "my-project",
expected: "my-project",
},
{
name: "from_cli",
args: []string{"--task-list", "cli-project"},
expected: "cli-project",
},
{
name: "cli_overrides_env",
env: "env-project",
args: []string{"--task-list", "cli-project"},
expected: "cli-project",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("CAGENT_TASK_LIST_ID", tt.env)

cmd := &cobra.Command{
RunE: func(*cobra.Command, []string) error {
return nil
},
}
runConfig := config.RuntimeConfig{}
addTaskListFlags(cmd, &runConfig)

cmd.SetArgs(tt.args)
err := cmd.Execute()

require.NoError(t, err)
assert.Equal(t, tt.expected, runConfig.TaskListID)
})
}
}
66 changes: 64 additions & 2 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -859,8 +859,9 @@ Included in `cagent` are a series of built-in tools that can greatly enhance the
toolsets:
- type: filesystem # Grants the agent filesystem access
- type: think # Enables the think tool
- type: todo # Enable the todo list tool
- type: todo # Enable the simple todo list tool
shared: boolean # Should the todo list be shared between agents (optional)
- type: tasks # Enable the tasks tool with dependencies and persistence (always shared)
- type: memory # Allows the agent to store memories to a local sqlite db
path: ./mem.db # Path to the sqlite database for memory storage (optional)
```
Expand All @@ -881,7 +882,7 @@ agents:

### Todo Tool

The todo tool helps agents manage task lists:
The todo tool helps agents manage simple task lists:

```yaml
agents:
Expand All @@ -891,6 +892,67 @@ agents:
- type: todo
```

### Tasks Tool

The tasks tool provides advanced task management with dependencies, blocking relationships, and automatic persistence:

```yaml
agents:
root:
# ... other config
toolsets:
- type: tasks
```

**Features:**
- **Dependencies**: Tasks can be blocked by other tasks (`blocked_by`)
- **Status tracking**: `pending`, `in-progress`, `completed`
- **Blocking enforcement**: Cannot start a blocked task until dependencies are completed
- **Cycle detection**: Prevents circular dependencies
- **Automatic persistence**: Tasks persist across sessions, stored in `~/.cagent/tasks/`
- **Git-aware**: Tasks are shared across all worktrees of the same git repository
- **Always shared**: All agents automatically share the same task list

**Available tools:**
- `create_task` - Create a single task with optional dependencies
- `create_tasks` - Create multiple tasks at once
- `update_tasks` - Update task status or owner
- `list_tasks` - List all tasks with visual indicators (✓ done, ■ in-progress, □ pending, ⚠ blocked)
- `add_task_dependency` - Add dependencies to an existing task
- `remove_task_dependency` - Remove dependencies from a task
- `get_blocked_tasks` - List tasks that are currently blocked

**Multi-agent sharing:**

Tasks are automatically shared across all agents - no configuration needed:

```yaml
agents:
coordinator:
toolsets:
- type: tasks
sub_agents: [backend, frontend]
backend:
toolsets:
- type: tasks
frontend:
toolsets:
- type: tasks
```

**Custom task list ID:**

By default, tasks are stored based on the git repository. You can override this with a custom ID:

```bash
# Via CLI flag
cagent run agent.yaml --task-list my-project

# Via environment variable
export CAGENT_TASK_LIST_ID="my-project"
cagent run agent.yaml
```

### Memory Tool

The memory tool provides persistent storage:
Expand Down
36 changes: 36 additions & 0 deletions e2e/testdata/shared_tasks.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
version: "2"

agents:
root:
model: openai/gpt-5-mini
description: Coordinator that delegates to specialized agents
instruction: |
You coordinate work using a shared task list.
Use transfer_task to delegate work to sub-agents.
sub_agents:
- backend
- frontend
toolsets:
- type: tasks
shared: true
- type: transfer_task

backend:
model: openai/gpt-5-mini
description: Backend developer
instruction: |
You handle backend tasks.
You share a task list with other agents.
toolsets:
- type: tasks
shared: true

frontend:
model: openai/gpt-5-mini
description: Frontend developer
instruction: |
You handle frontend tasks.
You share a task list with other agents.
toolsets:
- type: tasks
shared: true
16 changes: 16 additions & 0 deletions e2e/testdata/tasks_dependencies.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
version: "2"

agents:
root:
model: openai/gpt-5-mini
description: Test agent for tasks with dependencies
instruction: |
You are a helpful assistant that uses tasks tools with dependencies.

When creating tasks:
- Use blocked_by to specify dependencies
- Use owner to assign tasks

Always use the tasks tools to track work.
toolsets:
- type: tasks
4 changes: 3 additions & 1 deletion golang_developer.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ agents:
<workflow>
The agent follows a deliberate approach to code changes. It begins by understanding what the user needs and searching for relevant code files and functions. Once it has a clear picture of the codebase structure, it makes necessary modifications while ensuring changes follow best practices and maintain consistency with existing code style.

For complex tasks with multiple steps, the agent uses the tasks tool to break down work, track dependencies, and monitor progress.

After making changes, the agent validates its work by running linters and tests. If issues arise, it returns to modification and continues this loop until all requirements are met and the code passes validation.
</workflow>

Expand Down Expand Up @@ -97,7 +99,7 @@ agents:
toolsets:
- type: filesystem
- type: shell
- type: todo
- type: tasks
- type: fetch
sub_agents:
- librarian
Expand Down
1 change: 1 addition & 0 deletions pkg/config/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type Config struct {
ModelsGateway string
GlobalCodeMode bool
WorkingDir string
TaskListID string // ID for persistent task list (from --task-list or CAGENT_TASK_LIST_ID)
}

func (runConfig *RuntimeConfig) Clone() *RuntimeConfig {
Expand Down
19 changes: 18 additions & 1 deletion pkg/fake/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -395,16 +395,33 @@ type streamReadResult struct {
// It properly handles context cancellation during blocking reads.
func StreamCopy(c echo.Context, resp *http.Response) error {
ctx := c.Request().Context()
writer := c.Response().Writer.(io.ReaderFrom)
writer, ok := c.Response().Writer.(io.ReaderFrom)
Copy link
Member

Choose a reason for hiding this comment

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

Even if this fixes something we should open this in a new PR

if !ok {
// Fallback to io.Copy if writer doesn't implement ReaderFrom
_, err := io.Copy(c.Response().Writer, resp.Body)
return err
}

// Use a channel to receive read results from a goroutine.
// This allows us to properly select on context cancellation
// even when the read is blocking.
resultCh := make(chan streamReadResult, 1)

for {
// Check context before starting new goroutine
if ctx.Err() != nil {
return nil
}

// Start a goroutine to perform the blocking read
go func() {
defer func() {
// Recover from panic if writer becomes invalid
if r := recover(); r != nil {
slog.Warn("StreamCopy recovered from panic", "panic", r)
resultCh <- streamReadResult{n: 0, err: io.EOF}
}
}()
n, err := writer.ReadFrom(io.LimitReader(resp.Body, 256))
resultCh <- streamReadResult{n: n, err: err}
}()
Expand Down
5 changes: 5 additions & 0 deletions pkg/paths/paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,8 @@ func GetHomeDir() string {
}
return filepath.Clean(homeDir)
}

// GetTasksDir returns the directory for storing task lists.
func GetTasksDir() string {
return filepath.Join(GetDataDir(), "tasks")
}
22 changes: 21 additions & 1 deletion pkg/teamloader/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ type ToolsetCreator func(ctx context.Context, toolset latest.Toolset, parentDir

// ToolsetRegistry manages the registration of toolset creators by type
type ToolsetRegistry struct {
creators map[string]ToolsetCreator
creators map[string]ToolsetCreator
sharedTasksTool *builtin.TasksTool // Shared instance for all agents
}

// NewToolsetRegistry creates a new empty toolset registry
Expand All @@ -48,6 +49,11 @@ func (r *ToolsetRegistry) Get(toolsetType string) (ToolsetCreator, bool) {

// CreateTool creates a toolset using the registered creator for the given type
func (r *ToolsetRegistry) CreateTool(ctx context.Context, toolset latest.Toolset, parentDir string, runConfig *config.RuntimeConfig) (tools.ToolSet, error) {
// Special case for tasks - always returns shared instance
if toolset.Type == "tasks" {
return r.GetOrCreateTasksTool(runConfig), nil
}

creator, ok := r.Get(toolset.Type)
if !ok {
return nil, fmt.Errorf("unknown toolset type: %s", toolset.Type)
Expand All @@ -59,6 +65,7 @@ func NewDefaultToolsetRegistry() *ToolsetRegistry {
r := NewToolsetRegistry()
// Register all built-in toolset creators
r.Register("todo", createTodoTool)
// Note: "tasks" is handled specially in CreateTool - no registration needed
Copy link
Member

Choose a reason for hiding this comment

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

remove comment

r.Register("memory", createMemoryTool)
r.Register("think", createThinkTool)
r.Register("shell", createShellTool)
Expand All @@ -80,6 +87,19 @@ func createTodoTool(_ context.Context, toolset latest.Toolset, _ string, _ *conf
return builtin.NewTodoTool(), nil
}

// GetOrCreateTasksTool returns the shared TasksTool instance, creating it if needed
func (r *ToolsetRegistry) GetOrCreateTasksTool(runConfig *config.RuntimeConfig) *builtin.TasksTool {
Copy link
Member

Choose a reason for hiding this comment

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

maybe we can sync.Once and also put the "tasks" toolset as a normal r.Register, no need to special case it

if r.sharedTasksTool == nil {
listID := runConfig.TaskListID
if listID == "" {
listID = builtin.DefaultTaskListID()
}
store := builtin.NewFileTaskStore(listID)
r.sharedTasksTool = builtin.NewTasksTool(store)
}
return r.sharedTasksTool
}

func createMemoryTool(_ context.Context, toolset latest.Toolset, parentDir string, runConfig *config.RuntimeConfig) (tools.ToolSet, error) {
var memoryPath string
if filepath.IsAbs(toolset.Path) {
Expand Down
Loading