fix(tasktool): use agent session key, persist plan on create, add docs

- Add WithToolSessionKey/ToolSessionKey context helpers to pkg/tools/base.go
- Inject opts.SessionKey into ctx in runAgentLoop so tasktool uses the
  same agent-scoped session key as conversation history instead of the
  bare channel:chatID composite (fixes plan cross-agent/scope collision)
- Call Save inside CreatePlan so plans persist after restart even on
  channels that never return a message ID (fixes data loss on non-Telegram)
- Add tasktool section to config/config.example.json and
  docs/tools_configuration.md (P3 documentation gap)
This commit is contained in:
Dmitrii Balabanov 2026-03-08 00:19:25 +02:00
parent e7585e8185
commit 8f40b0099f
6 changed files with 78 additions and 7 deletions

View file

@ -431,6 +431,15 @@
},
"write_file": {
"enabled": true
},
"tasktool": {
"enabled": true,
"icons": {
"pending": "🔘",
"in_progress": "🟡",
"completed": "🟢",
"failed": "🔴"
}
}
},
"heartbeat": {

View file

@ -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_<SECTION>_<KEY>`:

View file

@ -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

View file

@ -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
}

View file

@ -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.
//

View file

@ -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 {