Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions cmd/chat_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ func optionalTools() []tool.Tool {
tool.TaskGetTool{},
tool.TaskListTool{},
tool.TaskUpdateTool{},
tool.TaskRunTool{},
tool.SleepTool{},
tool.CronCreateTool{},
tool.CronDeleteTool{},
Expand Down
45 changes: 45 additions & 0 deletions internal/engine/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package engine

import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
Expand Down Expand Up @@ -184,6 +185,7 @@ func NewSessionWithClient(chat ChatClient, provider, model, systemPrompt string,
recordVerification: s.recordVerificationObservation,
lifecycle: s.life,
appendSystem: s.AppendSystemContext,
taskExec: s.taskExecFromAgentSpawn(),
})
s.refreshContextWindowCache()
s.life.SetAgentsAccumulator(agentsAccum)
Expand All @@ -209,6 +211,49 @@ func (s *Session) ReattachTransport(chat ChatClient, provider string, deployment
_ = deploymentRouting
}

// taskExecFromAgentSpawn returns the TaskRun executor: it spawns a general
// sub-agent to perform a stored task, feeding the task's description, active
// form, and checkpoint as the prompt. Nil agent-spawn capability disables the
// executor (the TaskRun tool then reports that no executor is configured).
func (s *Session) taskExecFromAgentSpawn() tool.TaskExecutorFunc {
return func(ctx context.Context, t *tool.Task) (string, error) {
if s == nil || s.tools == nil || s.tools.AgentSpawnFn() == nil {
return "", fmt.Errorf("task execution unavailable: no agent spawn capability")
}
prompt := "Execute the following task and report results.\n\nSubject: " + t.Subject +
"\n\nDescription: " + t.Description
if t.ActiveForm != "" {
prompt += "\n\n(You are working on: " + t.ActiveForm + ")"
}
if len(t.Checkpoint) > 0 {
b, _ := json.Marshal(t.Checkpoint)
prompt += "\n\nPrior progress (checkpoint): " + string(b)
}
res, err := s.tools.AgentSpawnFn()(ctx, agentcontracts.SpawnRequest{
Prompt: prompt,
Description: "Execute task " + t.ID,
SubagentType: "general",
})
if err != nil {
return "", err
}
if res.Status == agentcontracts.StatusFailed {
if res.Output != "" {
return "", fmt.Errorf("%s", res.Output)
}
return "", fmt.Errorf("task agent reported failure")
}
out := res.Output
if res.Summary != "" {
if out != "" {
out += "\n"
}
out += res.Summary
}
return out, nil
}
}

// SubSession clones transport and routing mode for explore/general sub-agents.
func (s *Session) SubSession(model, systemPrompt string, registry *tool.Registry) *Session {
if registry == nil {
Expand Down
2 changes: 2 additions & 0 deletions internal/engine/tool_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ type toolExecutionDeps struct {
recordVerification func(types.ToolCall, string, bool)
lifecycle *LifecycleService
appendSystem func(string)
taskExec tool.TaskExecutorFunc
}

// NewToolService constructs a ToolService with the given registry.
Expand Down Expand Up @@ -395,6 +396,7 @@ func (s *ToolService) ExecuteOne(ctx context.Context, tc types.ToolCall, overrid
AvailableTools: available,
Registry: s.registry,
AutoCommit: s.AutoCommit(),
TaskExecutor: s.deps.taskExec,
})
// Bridge session sandbox policy onto the context so Bash/PowerShell
// WrapCommand actually applies. Path guards already read ToolContext.SandboxMode;
Expand Down
9 changes: 6 additions & 3 deletions internal/tool/task_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@ type TaskStatus string
const (
TaskStatusPending TaskStatus = "pending"
TaskStatusInProgress TaskStatus = "in_progress"
TaskStatusReviewing TaskStatus = "reviewing"
TaskStatusCompleted TaskStatus = "completed"
TaskStatusFailed TaskStatus = "failed"
TaskStatusSkipped TaskStatus = "skipped"
TaskStatusCancelled TaskStatus = "cancelled"
)

// DefaultMaxAttempts is the retry budget used when a task does not declare
Expand Down Expand Up @@ -399,7 +402,7 @@ func (TaskUpdateTool) Parameters() map[string]interface{} {
"type": "object",
"properties": map[string]interface{}{
"taskId": map[string]interface{}{"type": "string", "description": "The ID of the task to update"},
"status": map[string]interface{}{"type": "string", "enum": []string{"pending", "in_progress", "completed", "failed"}, "description": "New task status"},
"status": map[string]interface{}{"type": "string", "enum": []string{"pending", "in_progress", "reviewing", "completed", "failed", "skipped", "cancelled"}, "description": "New task status"},
"owner": map[string]interface{}{"type": "string", "description": "Agent name to assign"},
"dependencies": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "object", "properties": map[string]interface{}{"targetId": map[string]interface{}{"type": "string"}, "type": map[string]interface{}{"type": "string", "enum": []string{"blocks", "related", "parent-child"}}}}, "description": "Replace dependencies"},
},
Expand All @@ -422,9 +425,9 @@ func (TaskUpdateTool) Execute(_ context.Context, input json.RawMessage) (string,
}
ok := globalTaskStore.Update(p.TaskID, func(t *Task) {
if p.Status != "" {
// Moving a failed task back to pending is an explicit replan
// Any explicit status change out of failed is a replan or terminal
// signal: reset the retry budget and error so it starts fresh.
if TaskStatus(p.Status) == TaskStatusPending && t.Status == TaskStatusFailed {
if t.Status == TaskStatusFailed && TaskStatus(p.Status) != TaskStatusFailed {
t.Attempts = 0
t.LastError = ""
}
Expand Down
Loading
Loading