diff options
| author | Claude <noreply@anthropic.com> | 2026-05-24 08:47:07 +0000 |
|---|---|---|
| committer | Claude <noreply@anthropic.com> | 2026-05-24 08:47:07 +0000 |
| commit | c7d95f3992d24f86ff71e5f3e18260a8ef8a09f0 (patch) | |
| tree | ca62aca2287952907cdde0263672a0911b37d30e /internal/executor/channel.go | |
| parent | 48a8e49cdda833f8af22e839ca3c102c40c97e17 (diff) | |
feat(executor): introduce AgentChannel seam for runner signals
Defines AgentChannel — the normalized interface by which a runner reports
agent-originated signals (AskUser, ReportSummary, SpawnSubtask,
RecordProgress) — plus a default storeChannel implementation backed by
storage. Runner.Run now takes an AgentChannel; the pool constructs one
per execution.
The file transport routes its post-exit summary detection through
ch.ReportSummary (buffered onto the execution so the pool still applies
its extract/synthesize fallbacks, no double-write). AskUser returns
ErrAgentBlocked since write-and-exit cannot answer in-session; question
persistence stays with the pool's BlockedError handling. SpawnSubtask
and RecordProgress are implemented and tested, ready for the MCP
transport in Phase 2 where the channel becomes fully load-bearing.
Store gains CreateEvent so the channel can emit agent_message events.
https://claude.ai/code/session_01SESwn7kQ7oP62trWw6pc39
Diffstat (limited to 'internal/executor/channel.go')
| -rw-r--r-- | internal/executor/channel.go | 117 |
1 files changed, 117 insertions, 0 deletions
diff --git a/internal/executor/channel.go b/internal/executor/channel.go new file mode 100644 index 0000000..76df94f --- /dev/null +++ b/internal/executor/channel.go @@ -0,0 +1,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, + }) +}
\ No newline at end of file |
