summaryrefslogtreecommitdiff
path: root/internal/executor/channel.go
blob: 76df94f3b0b378c9146f45f1a012a800ab80f636 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
package executor

import (
	"context"
	"encoding/json"
	"errors"
	"time"

	"github.com/thepeterstone/claudomator/internal/event"
	"github.com/thepeterstone/claudomator/internal/storage"
	"github.com/thepeterstone/claudomator/internal/task"
	"github.com/google/uuid"
)

// ErrAgentBlocked is returned by AgentChannel.AskUser when the transport cannot
// deliver an answer within the current run — e.g. the file transport, which can
// only write-and-exit. Runners translate it into a *BlockedError so the task
// blocks and resumes later. Transports that can suspend the agent in-session
// (MCP, Phase 2) instead block and return the answer.
var ErrAgentBlocked = errors.New("agent blocked awaiting user input")

// SubtaskSpec describes a child task an agent wants to spawn.
type SubtaskSpec struct {
	Name         string
	Instructions string
	Model        string
	MaxBudgetUSD float64
}

// AgentChannel is how a Runner reports agent-originated signals to the rest of
// the system. Implementations translate these into stored artifacts and events.
// The transport by which a Runner detects these signals — post-exit files
// today, MCP/tool-use in Phase 2 — is the Runner's private concern.
type AgentChannel interface {
	// AskUser records that the agent needs human input. Transports that can
	// suspend the agent in-session return the answer; those that cannot return
	// ErrAgentBlocked.
	AskUser(ctx context.Context, questionJSON string) (answer string, err error)
	// ReportSummary records the agent's final summary text.
	ReportSummary(ctx context.Context, summary string) error
	// SpawnSubtask creates a child task and returns its ID.
	SpawnSubtask(ctx context.Context, spec SubtaskSpec) (taskID string, err error)
	// RecordProgress records a free-form progress note from the agent.
	RecordProgress(ctx context.Context, message string) error
}

// channelStore is the subset of storage the default channel needs.
type channelStore interface {
	CreateTask(t *task.Task) error
	CreateEvent(e *event.Event) error
}

// storeChannel is the default AgentChannel backed by storage. Summary reports
// are buffered onto the execution record so the pool persists them once (with
// its extract/synthesize fallbacks); other signals are written immediately.
type storeChannel struct {
	store  channelStore
	taskID string
	exec   *storage.Execution
}

var _ AgentChannel = (*storeChannel)(nil)

func newStoreChannel(store channelStore, taskID string, exec *storage.Execution) *storeChannel {
	return &storeChannel{store: store, taskID: taskID, exec: exec}
}

// AskUser on the default (file-transport) channel cannot deliver an answer
// in-session, so it always reports the run as blocked. Question persistence is
// owned by the pool's BlockedError handling.
func (c *storeChannel) AskUser(_ context.Context, _ string) (string, error) {
	return "", ErrAgentBlocked
}

// ReportSummary buffers the summary onto the execution record. The pool persists
// it (preferring this value over its extract/synthesize fallbacks).
func (c *storeChannel) ReportSummary(_ context.Context, summary string) error {
	if c.exec != nil {
		c.exec.Summary = summary
	}
	return nil
}

func (c *storeChannel) SpawnSubtask(_ context.Context, spec SubtaskSpec) (string, error) {
	now := time.Now().UTC()
	child := &task.Task{
		ID:           uuid.NewString(),
		Name:         spec.Name,
		ParentTaskID: c.taskID,
		Agent: task.AgentConfig{
			Type:         "claude",
			Model:        spec.Model,
			Instructions: spec.Instructions,
			MaxBudgetUSD: spec.MaxBudgetUSD,
		},
		Priority:  task.PriorityNormal,
		State:     task.StatePending,
		CreatedAt: now,
		UpdatedAt: now,
	}
	if err := c.store.CreateTask(child); err != nil {
		return "", err
	}
	return child.ID, nil
}

func (c *storeChannel) RecordProgress(_ context.Context, message string) error {
	payload, _ := json.Marshal(struct {
		Message string `json:"message"`
	}{Message: message})
	return c.store.CreateEvent(&event.Event{
		TaskID:  c.taskID,
		Kind:    event.KindAgentMessage,
		Actor:   event.ActorAgent,
		Payload: payload,
	})
}