diff --git a/config/config.example.json b/config/config.example.json index 8e58e3252..47f04f906 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -431,6 +431,15 @@ }, "write_file": { "enabled": true + }, + "tasktool": { + "enabled": true, + "icons": { + "pending": "🔘", + "in_progress": "🟡", + "completed": "🟢", + "failed": "🔴" + } } }, "heartbeat": { diff --git a/docs/tools_configuration.md b/docs/tools_configuration.md index e64a3a107..bde6b12fb 100644 --- a/docs/tools_configuration.md +++ b/docs/tools_configuration.md @@ -206,6 +206,48 @@ The skills tool configures skill discovery and installation via registries like } ``` +## Task Tool + +The task tool lets the agent create and track a step-by-step execution plan visible to the user in real time. + +### Config + +| Config | Type | Default | Description | +| ------------------- | ------ | ------- | -------------------------------------- | +| `enabled` | bool | true | Enable the task planning tool | +| `icons.pending` | string | 🔘 | Icon shown for pending tasks | +| `icons.in_progress` | string | 🟡 | Icon shown for tasks in progress | +| `icons.completed` | string | 🟢 | Icon shown for completed tasks | +| `icons.failed` | string | 🔴 | Icon shown for failed tasks | + +### Configuration Example + +```json +{ + "tools": { + "tasktool": { + "enabled": true, + "icons": { + "pending": "🔘", + "in_progress": "🟡", + "completed": "🟢", + "failed": "🔴" + } + } + } +} +``` + +### Environment Variables + +| Variable | Description | +| -------------------------------------------- | ------------------------ | +| `PICOCLAW_TOOLS_TASK_TOOL_ENABLED` | Enable/disable the tool | +| `PICOCLAW_TOOLS_TASK_TOOL_ICONS_PENDING` | Pending task icon | +| `PICOCLAW_TOOLS_TASK_TOOL_ICONS_IN_PROGRESS` | In-progress task icon | +| `PICOCLAW_TOOLS_TASK_TOOL_ICONS_COMPLETED` | Completed task icon | +| `PICOCLAW_TOOLS_TASK_TOOL_ICONS_FAILED` | Failed task icon | + ## Environment Variables All configuration options can be overridden via environment variables with the format `PICOCLAW_TOOLS_
_`: diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 080012ee3..588d55c50 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -798,6 +798,8 @@ func (al *AgentLoop) runAgentLoop( agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage) // 3. Run LLM iteration loop + // Inject session key so tools (e.g. tasktool) can look it up from context. + ctx = tools.WithToolSessionKey(ctx, opts.SessionKey) finalContent, iteration, err := al.runLLMIteration(ctx, agent, messages, opts) if err != nil { return "", err diff --git a/pkg/session/tasks.go b/pkg/session/tasks.go index b4adaeae4..a3ba0f6d3 100644 --- a/pkg/session/tasks.go +++ b/pkg/session/tasks.go @@ -97,6 +97,9 @@ func (tm *TaskManager) CreatePlan(sessionKey string, tasks []Task) *SessionTasks } copy(st.Tasks, tasks) tm.tasks[sessionKey] = st + // Persist immediately so the plan survives a restart even when no + // message ID is available (e.g. channels that don't return a message ID). + go func() { _ = tm.Save(sessionKey) }() return st } diff --git a/pkg/tools/base.go b/pkg/tools/base.go index ea0fb792a..f930742eb 100644 --- a/pkg/tools/base.go +++ b/pkg/tools/base.go @@ -21,8 +21,9 @@ type Tool interface { type toolCtxKey struct{ name string } var ( - ctxKeyChannel = &toolCtxKey{"channel"} - ctxKeyChatID = &toolCtxKey{"chatID"} + ctxKeyChannel = &toolCtxKey{"channel"} + ctxKeyChatID = &toolCtxKey{"chatID"} + ctxKeySessionKey = &toolCtxKey{"sessionKey"} ) // WithToolContext returns a child context carrying channel and chatID. @@ -32,6 +33,11 @@ func WithToolContext(ctx context.Context, channel, chatID string) context.Contex return ctx } +// WithToolSessionKey returns a child context carrying the session key. +func WithToolSessionKey(ctx context.Context, sessionKey string) context.Context { + return context.WithValue(ctx, ctxKeySessionKey, sessionKey) +} + // ToolChannel extracts the channel from ctx, or "" if unset. func ToolChannel(ctx context.Context) string { v, _ := ctx.Value(ctxKeyChannel).(string) @@ -44,6 +50,12 @@ func ToolChatID(ctx context.Context) string { return v } +// ToolSessionKey extracts the session key from ctx, or "" if unset. +func ToolSessionKey(ctx context.Context) string { + v, _ := ctx.Value(ctxKeySessionKey).(string) + return v +} + // AsyncCallback is a function type that async tools use to notify completion. // When an async tool finishes its work, it calls this callback with the result. // diff --git a/pkg/tools/tasktool.go b/pkg/tools/tasktool.go index ece838073..9885ac4c5 100644 --- a/pkg/tools/tasktool.go +++ b/pkg/tools/tasktool.go @@ -98,11 +98,14 @@ func (t *TaskTool) Execute(ctx context.Context, args map[string]any) *ToolResult channel := ToolChannel(ctx) chatID := ToolChatID(ctx) - // We use the same combination for task state as session manager might. - // But note: AgentLoop uses scopes out of routes. We'll use channel:chatID as implicit for now - // To be perfectly aligned with SessionKey, we'd need to extract SessionKey from context. - // We'll add SessionKey to context later if needed, or just use channel:chatID for tasks since planning is chat-specific. - sessionKey := fmt.Sprintf("%s:%s", channel, chatID) + // Use the agent-scoped session key injected by the agent loop so that task + // plans are stored under the same key used for conversation history. + // Fall back to channel:chatID only when running outside of an agent loop + // (e.g. unit tests or direct tool invocations). + sessionKey := ToolSessionKey(ctx) + if sessionKey == "" { + sessionKey = fmt.Sprintf("%s:%s", channel, chatID) + } action, ok := args["action"].(string) if !ok {