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
124 changes: 124 additions & 0 deletions docs/plans/2026-04-04-executor-mesh-support-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# Executor Mesh Support Design

**Date:** 2026-04-04
**Status:** Proposed
**Repo:** workflow-plugin-agent
**Downstream:** ratchet-cli agent mesh (see `ratchet-cli/docs/plans/2026-04-04-agent-mesh-design.md`)

## Problem

The current `executor.Execute()` is a closed synchronous loop. It works for single-agent task execution but lacks three capabilities required by ratchet-cli's agent mesh:

1. **No message injection** — When Agent B sends a message to Agent A via the mesh, there's no way to push that message into A's running conversation. The loop only appends tool results; external input can't arrive mid-loop.

2. **No event streaming** — The executor returns a final `Result` but emits no real-time events. The mesh (and TUI) need to observe tool calls, thinking blocks, token deltas, and tool results as they happen.

3. **No custom termination** — The loop exits when the LLM stops making tool calls or max iterations is reached. Mesh agents need to signal "done" explicitly (e.g. by writing to the blackboard status section), which the executor can't detect.

## Design

Extend `executor.Config` with three optional fields. All are backward-compatible — existing callers that don't set them get identical behavior to today.

### 1. Inbox Channel

```go
// Config additions:
type Config struct {
// ... existing fields ...

// Inbox receives external messages injected into the conversation
// between loop iterations. Nil means no external messages.
Inbox <-chan provider.Message
}
```

**Behavior:** At the top of each loop iteration (before the compaction check and before the `provider.Chat()` call), drain all pending messages from `Inbox` and append them to the conversation. This is non-blocking — if no messages are waiting, the loop proceeds immediately. Injected messages are therefore visible to the subsequent compaction check.

This lets the mesh push messages from other agents into a running agent's conversation as user-role messages.

### 2. Event Callback

```go
// EventType identifies what happened in the executor loop.
type EventType string

const (
EventToolCallStart EventType = "tool_call_start"
EventToolCallResult EventType = "tool_call_result"
EventThinking EventType = "thinking"
EventText EventType = "text"
EventIteration EventType = "iteration"
EventCompleted EventType = "completed"
EventFailed EventType = "failed"
)

// Event is emitted during execution for real-time observation.
type Event struct {
Type EventType
AgentID string
Iteration int
Content string // text content or thinking trace
ToolName string // for tool_call_start/result
ToolCallID string // for tool_call_start/result
ToolArgs map[string]any // for tool_call_start
ToolResult string // for tool_call_result
ToolError bool // for tool_call_result
Error string // for failed events
}

// Config additions:
type Config struct {
// ... existing fields ...

// OnEvent is called for each executor event. Nil means no events emitted.
// The callback must not block — use a buffered channel internally if needed.
OnEvent func(Event)
}
```

**Behavior:** Emit events at each point in the loop: iteration start, after LLM response (thinking + text), before/after each tool call, and on completion/failure. The callback is synchronous and must not block — the mesh wraps it in a channel send internally.

### 3. Custom Termination

```go
// Config additions:
type Config struct {
// ... existing fields ...

// ShouldStop is called after each tool execution round. If it returns
// a non-empty string, the loop exits with status "completed" and that
// string as the Result.Content. Nil means no custom termination.
ShouldStop func() (reason string)
}
```

**Behavior:** After all tool calls in an iteration are processed (but before the next `provider.Chat()` call), call `ShouldStop()`. If it returns a non-empty string, exit the loop with status "completed". The mesh sets this to check the blackboard status section for a "done" or "approved" entry written by the current agent.

## Integration Points

The mesh uses these three features together:

```
LocalNode.Run():
1. Create tools.Registry with blackboard + messaging tools
2. Create a buffered inbox channel
3. Set OnEvent to forward events to the mesh's TeamEvent stream
4. Set ShouldStop to check blackboard["status"][myNodeID] == "done"
5. Call executor.Execute(ctx, cfg, systemPrompt, task, nodeID)
6. Mesh routes incoming messages to the inbox channel
```

## Scope

**In scope:**
- Three new optional fields on `executor.Config`
- Inbox drain logic in the main loop
- Event emission at each loop point
- ShouldStop check after tool execution
- Unit tests for each new feature
- Backward compatibility: zero-value Config behaves identically to today

**Out of scope:**
- Streaming provider support in the executor (already available via `provider.Stream()`, used separately)
- Changes to the Provider interface
- Changes to the Tool interface
118 changes: 118 additions & 0 deletions docs/plans/2026-04-04-executor-mesh-support-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# Executor Mesh Support Implementation Plan

**Date:** 2026-04-04
**Design:** [2026-04-04-executor-mesh-support-design.md](2026-04-04-executor-mesh-support-design.md)
**Repo:** workflow-plugin-agent
**Downstream:** ratchet-cli agent mesh — implement this plan first, tag a release, then proceed with ratchet-cli mesh implementation.

## Steps

### Step 1: Event Types

Create `executor/events.go`.

- `EventType` string type with constants: `EventToolCallStart`, `EventToolCallResult`, `EventThinking`, `EventText`, `EventIteration`, `EventCompleted`, `EventFailed`
- `Event` struct with fields: Type, AgentID, Iteration, Content, ToolName, ToolCallID, ToolArgs, ToolResult, ToolError, Error

**Tests:** None (pure types).

**Files:** `executor/events.go`

---

### Step 2: Config Extensions

Modify `executor/executor.go`.

- Add three fields to `Config`:
- `Inbox <-chan provider.Message`
- `OnEvent func(Event)`
- `ShouldStop func() (reason string)`
- No changes to default behavior when fields are nil

**Tests:** Verify existing tests still pass with zero-value new fields.

**Files:** `executor/executor.go`

---

### Step 3: Inbox Drain

Modify `executor/executor.go` — the main loop in `Execute()`.

- At the top of each iteration (before compaction is checked and before `provider.Chat()`), call a new `drainInbox()` helper
- `drainInbox()` does a non-blocking select loop on `cfg.Inbox`, appending any received messages to the conversation so injected messages are visible to subsequent compaction checks
- If `cfg.Inbox` is nil, skip entirely
- Record drained messages via TranscriptRecorder

**Tests:** `executor_test.go` — test with a `ChannelProvider` and a buffered inbox channel. Send a message mid-execution, verify it appears in the conversation context (visible via tool call args or final response referencing the injected content).

**Files:** `executor/executor.go`, `executor/executor_test.go`

---

### Step 4: Event Emission

Modify `executor/executor.go` — emit events at each loop point.

- Helper: `emit(cfg, event)` — calls `cfg.OnEvent(event)` if non-nil
- Emit `EventIteration` at start of each loop iteration
- Emit `EventThinking` after LLM response if `resp.Thinking != ""`
- Emit `EventText` after LLM response if `resp.Content != ""`
- Emit `EventToolCallStart` before each tool execution
- Emit `EventToolCallResult` after each tool execution
- Emit `EventCompleted` or `EventFailed` at loop exit

**Tests:** `executor_test.go` — run with a scripted provider and an `OnEvent` callback that collects events into a slice. Verify the correct sequence: iteration → thinking → text → tool_call_start → tool_call_result → completed.

**Files:** `executor/executor.go`, `executor/executor_test.go`

---

### Step 5: Custom Termination

Modify `executor/executor.go` — add ShouldStop check.

- After all tool calls in an iteration are processed, before the next loop iteration, call `cfg.ShouldStop()` if non-nil
- If it returns a non-empty string, emit `EventCompleted` and return `Result{Content: reason, Status: "completed"}`
- If nil, skip (existing behavior)

**Tests:** `executor_test.go` — scripted provider that makes tool calls, ShouldStop returns non-empty after 2 iterations. Verify loop exits early with "completed" status.

**Files:** `executor/executor.go`, `executor/executor_test.go`

---

### Step 6: Integration Test

Create `executor/mesh_support_test.go`.

- End-to-end test simulating mesh usage: scripted provider, inbox channel with delayed messages, OnEvent collector, ShouldStop after seeing a specific tool call
- Verify: inbox messages appear in conversation, events stream in correct order, early termination works
- Uses `ScriptedProvider` — no real LLM needed

**Files:** `executor/mesh_support_test.go`

---

## Dependency Graph

```mermaid
graph LR
S1[Step 1: Event Types] --> S4[Step 4: Event Emission]
S2[Step 2: Config Extensions] --> S3[Step 3: Inbox Drain]
S2 --> S4
S2 --> S5[Step 5: Custom Termination]
S3 --> S6[Step 6: Integration Test]
S4 --> S6
S5 --> S6
```

**Parallelizable:** Steps 3, 4, 5 can be done in parallel after steps 1-2.

## Release

After all steps pass:
1. Tag `v0.5.3` (or next minor) in workflow-plugin-agent
2. Update ratchet-cli's `go.mod` to reference the new tag
3. Proceed with ratchet-cli mesh implementation plan
28 changes: 28 additions & 0 deletions executor/events.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package executor

// EventType identifies what happened in the executor loop.
type EventType string

const (
EventToolCallStart EventType = "tool_call_start"
EventToolCallResult EventType = "tool_call_result"
EventThinking EventType = "thinking"
EventText EventType = "text"
EventIteration EventType = "iteration"
EventCompleted EventType = "completed"
EventFailed EventType = "failed"
)

// Event is emitted during execution for real-time observation.
type Event struct {
Type EventType `json:"type"`
AgentID string `json:"agent_id,omitempty"`
Iteration int `json:"iteration,omitempty"`
Content string `json:"content,omitempty"` // text content or thinking trace
ToolName string `json:"tool_name,omitempty"` // for tool_call_start/result
ToolCallID string `json:"tool_call_id,omitempty"` // for tool_call_start/result
ToolArgs map[string]any `json:"tool_args,omitempty"` // for tool_call_start
ToolResult string `json:"tool_result,omitempty"` // for tool_call_result
ToolError bool `json:"tool_error,omitempty"` // for tool_call_result
Error string `json:"error,omitempty"` // for failed events
}
Loading
Loading