diff --git a/docs/plans/2026-04-04-executor-mesh-support-design.md b/docs/plans/2026-04-04-executor-mesh-support-design.md new file mode 100644 index 0000000..ec0c1b6 --- /dev/null +++ b/docs/plans/2026-04-04-executor-mesh-support-design.md @@ -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 diff --git a/docs/plans/2026-04-04-executor-mesh-support-plan.md b/docs/plans/2026-04-04-executor-mesh-support-plan.md new file mode 100644 index 0000000..9ab67d5 --- /dev/null +++ b/docs/plans/2026-04-04-executor-mesh-support-plan.md @@ -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 diff --git a/executor/events.go b/executor/events.go new file mode 100644 index 0000000..267cce0 --- /dev/null +++ b/executor/events.go @@ -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 +} diff --git a/executor/executor.go b/executor/executor.go index 55b902f..40e24ce 100644 --- a/executor/executor.go +++ b/executor/executor.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "log" "strings" "time" @@ -54,6 +55,19 @@ type Config struct { // TaskID and ProjectID are used for transcript recording. TaskID string ProjectID string + + // Inbox receives external messages injected into the conversation + // between loop iterations. Nil means no external messages. + Inbox <-chan provider.Message + + // 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) + + // 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) } // Result is the outcome of an Execute call. @@ -151,6 +165,21 @@ func Execute(ctx context.Context, cfg Config, systemPrompt, userTask, agentID st for iterCount < cfg.MaxIterations { iterCount++ + // Emit iteration event + emit(cfg, Event{ + Type: EventIteration, + AgentID: agentID, + Iteration: iterCount, + }) + + // Drain inbox: append any externally injected messages to the conversation + // BEFORE the compaction check, so compaction accounts for injected messages. + var inboxClosed bool + messages, inboxClosed = drainInbox(ctx, cfg, messages, agentID, iterCount) + if inboxClosed { + cfg.Inbox = nil // Avoid unnecessary selects on subsequent iterations. + } + // Context window management: compact if approaching model's token limit. if cm.NeedsCompaction(messages) { messages = cm.Compact(ctx, messages, cfg.Provider) @@ -176,6 +205,12 @@ func Execute(ctx context.Context, cfg Config, systemPrompt, userTask, agentID st resp, err := cfg.Provider.Chat(ctx, messages, toolDefs) if err != nil { errMsg := fmt.Sprintf("LLM call failed at iteration %d: %v", iterCount, err) + emit(cfg, Event{ + Type: EventFailed, + AgentID: agentID, + Iteration: iterCount, + Error: errMsg, + }) return &Result{ Content: errMsg, Status: "failed", @@ -187,6 +222,26 @@ func Execute(ctx context.Context, cfg Config, systemPrompt, userTask, agentID st finalContent = resp.Content finalThinking = resp.Thinking + // Emit thinking event + if resp.Thinking != "" { + emit(cfg, Event{ + Type: EventThinking, + AgentID: agentID, + Iteration: iterCount, + Content: resp.Thinking, + }) + } + + // Emit text event + if resp.Content != "" { + emit(cfg, Event{ + Type: EventText, + AgentID: agentID, + Iteration: iterCount, + Content: resp.Content, + }) + } + // Record assistant response _ = cfg.Transcript.Record(ctx, TranscriptEntry{ ID: uuid.New().String(), @@ -216,6 +271,18 @@ func Execute(ctx context.Context, cfg Config, systemPrompt, userTask, agentID st var resultStr string var isError bool + // Emit tool call start event with a shallow copy of the arguments so + // that OnEvent handlers cannot replace top-level keys used by tool + // execution (nested values are still shared; see copyArgs). + emit(cfg, Event{ + Type: EventToolCallStart, + AgentID: agentID, + Iteration: iterCount, + ToolName: tc.Name, + ToolCallID: tc.ID, + ToolArgs: copyArgs(tc.Arguments), + }) + if cfg.ToolRegistry != nil { // Build tool context with agent/task IDs toolCtx := tools.WithAgentID(ctx, agentID) @@ -239,6 +306,12 @@ func Execute(ctx context.Context, cfg Config, systemPrompt, userTask, agentID st // Handle approval gates if tc.Name == "request_approval" && !isError { if approvalOutput, breakLoop := handleApprovalWait(ctx, resultStr, cfg); breakLoop { + emit(cfg, Event{ + Type: EventFailed, + AgentID: agentID, + Iteration: iterCount, + Error: approvalOutput, + }) return &Result{ Content: approvalOutput, Status: "approval_timeout", @@ -253,6 +326,12 @@ func Execute(ctx context.Context, cfg Config, systemPrompt, userTask, agentID st // Handle human request blocking if tc.Name == "request_human" && !isError { if blockingOutput, breakLoop := handleHumanRequestWait(ctx, resultStr, cfg); breakLoop { + emit(cfg, Event{ + Type: EventFailed, + AgentID: agentID, + Iteration: iterCount, + Error: blockingOutput, + }) return &Result{ Content: blockingOutput, Status: "request_expired", @@ -285,6 +364,17 @@ func Execute(ctx context.Context, cfg Config, systemPrompt, userTask, agentID st ToolCallID: tc.ID, }) + // Emit tool call result event + emit(cfg, Event{ + Type: EventToolCallResult, + AgentID: agentID, + Iteration: iterCount, + ToolName: tc.Name, + ToolCallID: tc.ID, + ToolResult: resultStr, + ToolError: isError, + }) + // Loop detection ld.Record(tc.Name, tc.Arguments, resultStr, isError) loopStatus, loopMsg := ld.Check() @@ -315,6 +405,12 @@ func Execute(ctx context.Context, cfg Config, systemPrompt, userTask, agentID st Role: provider.RoleUser, Content: "[SYSTEM] " + breakMsg, }) + emit(cfg, Event{ + Type: EventFailed, + AgentID: agentID, + Iteration: iterCount, + Error: breakMsg, + }) return &Result{ Content: breakMsg, Status: "loop_detected", @@ -323,6 +419,24 @@ func Execute(ctx context.Context, cfg Config, systemPrompt, userTask, agentID st }, nil } } + + // Custom termination: check ShouldStop after all tool calls are processed. + if cfg.ShouldStop != nil { + if reason := cfg.ShouldStop(); reason != "" { + emit(cfg, Event{ + Type: EventCompleted, + AgentID: agentID, + Iteration: iterCount, + Content: reason, + }) + return &Result{ + Content: reason, + Status: "completed", + Iterations: iterCount, + Thinking: finalThinking, + }, nil + } + } } // Auto-extraction: save key facts from the conversation to persistent memory. @@ -343,6 +457,14 @@ func Execute(ctx context.Context, cfg Config, systemPrompt, userTask, agentID st } } + // Emit completed event + emit(cfg, Event{ + Type: EventCompleted, + AgentID: agentID, + Iteration: iterCount, + Content: finalContent, + }) + return &Result{ Content: finalContent, Thinking: finalThinking, @@ -428,3 +550,86 @@ func handleHumanRequestWait(ctx context.Context, toolResult string, cfg Config) return "", false } } + +// emit calls cfg.OnEvent if non-nil. It recovers from any panic in the +// handler so that a misbehaving observer cannot crash the executor run. +func emit(cfg Config, event Event) { + if cfg.OnEvent == nil { + return + } + func() { + defer func() { + if r := recover(); r != nil { + log.Printf("executor: OnEvent handler panicked (event=%s): %v", event.Type, r) + } + }() + cfg.OnEvent(event) + }() +} + +// copyArgs returns a shallow copy of the arguments map so that event handlers +// cannot mutate top-level keys used by tool execution. Note: nested maps or +// slices are still shared, so deeply-nested mutations by an OnEvent handler +// would still be visible to the tool. This is an acceptable trade-off given +// that tool arguments rarely contain nested mutable structures. +func copyArgs(args map[string]any) map[string]any { + if args == nil { + return nil + } + cp := make(map[string]any, len(args)) + for k, v := range args { + cp[k] = v + } + return cp +} + +// drainInboxMaxMessages is the maximum number of messages drained from Inbox +// per iteration to prevent a fast producer from starving the executor loop. +const drainInboxMaxMessages = 100 + +// drainInbox non-blockingly drains pending messages from cfg.Inbox +// and appends them to the conversation. At most drainInboxMaxMessages are +// consumed per call to prevent starvation. Returns the updated message slice +// and a boolean indicating whether the inbox channel was closed. +// Inbox messages are normalized to RoleUser with tool-related fields cleared to +// prevent prompt injection via unexpected roles. If cfg.Inbox is nil, returns +// messages unchanged. +func drainInbox(ctx context.Context, cfg Config, messages []provider.Message, agentID string, iteration int) ([]provider.Message, bool) { + if cfg.Inbox == nil { + return messages, false + } + if ctx.Err() != nil { + return messages, false + } + for i := 0; i < drainInboxMaxMessages; i++ { + select { + case <-ctx.Done(): + return messages, false + case msg, ok := <-cfg.Inbox: + if !ok { + // Channel closed — stop draining. + return messages, true + } + // Normalize to RoleUser and clear tool-related fields to prevent + // prompt injection via unexpected roles (system, assistant, tool). + normalized := provider.Message{ + Role: provider.RoleUser, + Content: msg.Content, + } + messages = append(messages, normalized) + _ = cfg.Transcript.Record(ctx, TranscriptEntry{ + ID: uuid.New().String(), + AgentID: agentID, + TaskID: cfg.TaskID, + ProjectID: cfg.ProjectID, + Iteration: iteration, + Role: normalized.Role, + Content: normalized.Content, + }) + default: + // No more pending messages. + return messages, false + } + } + return messages, false +} diff --git a/executor/executor_test.go b/executor/executor_test.go index fcda8dd..3b97a3a 100644 --- a/executor/executor_test.go +++ b/executor/executor_test.go @@ -215,6 +215,418 @@ func TestExecute_NullApproverIsDefault(t *testing.T) { } } +// TestExecute_InboxDrain verifies that messages from the Inbox channel are injected into the conversation. +func TestExecute_InboxDrain(t *testing.T) { + inbox := make(chan provider.Message, 2) + // Pre-fill inbox with an external message before execution starts. + inbox <- provider.Message{Role: provider.RoleUser, Content: "Message from Agent B: the secret is 42"} + + var capturedMessages []provider.Message + callN := 0 + p := &captureProvider{ + onChat: func(msgs []provider.Message) (*provider.Response, error) { + callN++ + capturedMessages = msgs + return &provider.Response{Content: "I see the message."}, nil + }, + } + + cfg := Config{ + Provider: p, + Inbox: inbox, + } + result, err := Execute(context.Background(), cfg, "sys", "task", "agent-1") + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result.Status != "completed" { + t.Errorf("Status: want completed, got %q", result.Status) + } + + // The inbox message should appear in the messages passed to Chat. + found := false + for _, msg := range capturedMessages { + if msg.Content == "Message from Agent B: the secret is 42" { + found = true + break + } + } + if !found { + t.Error("expected inbox message to appear in conversation context") + } +} + +// TestExecute_InboxNormalizesRole verifies that inbox messages with non-user roles +// are normalized to RoleUser to prevent prompt injection. +func TestExecute_InboxNormalizesRole(t *testing.T) { + inbox := make(chan provider.Message, 4) + inbox <- provider.Message{Role: provider.RoleSystem, Content: "injected system message"} + inbox <- provider.Message{Role: provider.RoleAssistant, Content: "injected assistant message"} + inbox <- provider.Message{Role: provider.RoleTool, Content: "injected tool message", ToolCallID: "tc-evil"} + inbox <- provider.Message{ + Role: provider.RoleAssistant, + Content: "injected assistant with tool calls", + ToolCalls: []provider.ToolCall{{ID: "tc-1", Name: "evil_tool", Arguments: map[string]any{}}}, + } + + var capturedMessages []provider.Message + p := &captureProvider{ + onChat: func(msgs []provider.Message) (*provider.Response, error) { + capturedMessages = msgs + return &provider.Response{Content: "ok"}, nil + }, + } + + _, err := Execute(context.Background(), Config{Provider: p, Inbox: inbox}, "sys", "task", "agent-1") + if err != nil { + t.Fatalf("Execute: %v", err) + } + + injectedContents := map[string]bool{ + "injected system message": true, + "injected assistant message": true, + "injected tool message": true, + "injected assistant with tool calls": true, + } + for _, msg := range capturedMessages { + if !injectedContents[msg.Content] { + continue + } + // All inbox messages must be normalized to RoleUser. + if msg.Role != provider.RoleUser { + t.Errorf("inbox message %q was not normalized to RoleUser; got role %q", msg.Content, msg.Role) + } + // Tool-related fields must be cleared. + if msg.ToolCallID != "" { + t.Errorf("inbox message %q ToolCallID was not cleared; got %q", msg.Content, msg.ToolCallID) + } + if len(msg.ToolCalls) != 0 { + t.Errorf("inbox message %q ToolCalls was not cleared; got %v", msg.Content, msg.ToolCalls) + } + } +} + +// TestExecute_EventSequence verifies events are emitted in the correct order. +func TestExecute_EventSequence(t *testing.T) { + callN := 0 + p := &callCountProvider{ + onChat: func() (*provider.Response, error) { + callN++ + if callN == 1 { + return &provider.Response{ + Content: "Let me think...", + Thinking: "Reasoning about the task", + ToolCalls: []provider.ToolCall{ + {ID: "tc-1", Name: "echo", Arguments: map[string]any{"msg": "hi"}}, + }, + }, nil + } + return &provider.Response{Content: "All done."}, nil + }, + } + + reg := tools.NewRegistry() + reg.Register(&simpleTool{ + name: "echo", + def: provider.ToolDef{Name: "echo", Description: "echoes input"}, + fn: func(_ context.Context, args map[string]any) (any, error) { + return map[string]any{"echoed": args["msg"]}, nil + }, + }) + + var events []Event + cfg := Config{ + Provider: p, + ToolRegistry: reg, + OnEvent: func(e Event) { + events = append(events, e) + }, + } + + result, err := Execute(context.Background(), cfg, "sys", "task", "agent-1") + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result.Status != "completed" { + t.Errorf("Status: want completed, got %q", result.Status) + } + + // Verify event sequence: iteration → thinking → text → tool_call_start → tool_call_result → iteration → text → completed + expectedTypes := []EventType{ + EventIteration, + EventThinking, + EventText, + EventToolCallStart, + EventToolCallResult, + EventIteration, + EventText, + EventCompleted, + } + if len(events) != len(expectedTypes) { + t.Fatalf("event count: want %d, got %d\nevents: %v", len(expectedTypes), len(events), eventTypes(events)) + } + for i, want := range expectedTypes { + if events[i].Type != want { + t.Errorf("event[%d]: want %q, got %q", i, want, events[i].Type) + } + } + + // Verify specific event content. + if events[1].Content != "Reasoning about the task" { + t.Errorf("thinking event content: want %q, got %q", "Reasoning about the task", events[1].Content) + } + if events[3].ToolName != "echo" { + t.Errorf("tool_call_start tool name: want %q, got %q", "echo", events[3].ToolName) + } + if events[4].ToolName != "echo" { + t.Errorf("tool_call_result tool name: want %q, got %q", "echo", events[4].ToolName) + } +} + +// TestExecute_ShouldStop verifies custom termination via ShouldStop. +func TestExecute_ShouldStop(t *testing.T) { + callN := 0 + p := &callCountProvider{ + onChat: func() (*provider.Response, error) { + callN++ + // Always return a tool call to keep the loop going. + return &provider.Response{ + ToolCalls: []provider.ToolCall{ + {ID: fmt.Sprintf("tc-%d", callN), Name: "counter", Arguments: map[string]any{"n": callN}}, + }, + }, nil + }, + } + + reg := tools.NewRegistry() + reg.Register(&simpleTool{ + name: "counter", + def: provider.ToolDef{Name: "counter", Description: "counts"}, + fn: func(_ context.Context, _ map[string]any) (any, error) { + return map[string]any{"ok": true}, nil + }, + }) + + stopAfter := 2 + iteration := 0 + cfg := Config{ + Provider: p, + ToolRegistry: reg, + MaxIterations: 10, + ShouldStop: func() string { + iteration++ + if iteration >= stopAfter { + return "agent marked done" + } + return "" + }, + } + + result, err := Execute(context.Background(), cfg, "sys", "task", "agent-1") + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result.Status != "completed" { + t.Errorf("Status: want completed, got %q", result.Status) + } + if result.Content != "agent marked done" { + t.Errorf("Content: want %q, got %q", "agent marked done", result.Content) + } + if result.Iterations > 3 { + t.Errorf("Iterations: want <= 3, got %d (should have stopped early)", result.Iterations) + } +} + +// TestExecute_EventOnFailure verifies that EventFailed is emitted on provider error. +func TestExecute_EventOnFailure(t *testing.T) { + p := &mockProvider{ + name: "mock", + chatErr: fmt.Errorf("LLM unavailable"), + } + + var events []Event + cfg := Config{ + Provider: p, + OnEvent: func(e Event) { + events = append(events, e) + }, + } + + result, err := Execute(context.Background(), cfg, "sys", "task", "agent-1") + if err != nil { + t.Fatalf("Execute returned error: %v", err) + } + if result.Status != "failed" { + t.Errorf("Status: want failed, got %q", result.Status) + } + + // Should see: iteration → failed + if len(events) < 2 { + t.Fatalf("expected at least 2 events, got %d", len(events)) + } + if events[0].Type != EventIteration { + t.Errorf("event[0]: want %q, got %q", EventIteration, events[0].Type) + } + if events[1].Type != EventFailed { + t.Errorf("event[1]: want %q, got %q", EventFailed, events[1].Type) + } + if events[1].Error == "" { + t.Error("failed event should have non-empty Error") + } +} + +// TestExecute_NilCallbacksBackwardCompat verifies that nil OnEvent, Inbox, ShouldStop work. +func TestExecute_NilCallbacksBackwardCompat(t *testing.T) { + p := &mockProvider{ + name: "mock", + chatResponse: &provider.Response{Content: "ok"}, + } + cfg := Config{ + Provider: p, + OnEvent: nil, + Inbox: nil, + ShouldStop: nil, + } + result, err := Execute(context.Background(), cfg, "sys", "task", "agent-1") + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result.Status != "completed" { + t.Errorf("Status: want completed, got %q", result.Status) + } +} + +// TestExecute_ToolArgsEventIsCopy verifies that mutating Event.ToolArgs in an OnEvent +// handler does not affect the tool invocation that follows. +func TestExecute_ToolArgsEventIsCopy(t *testing.T) { + callN := 0 + p := &callCountProvider{ + onChat: func() (*provider.Response, error) { + callN++ + if callN == 1 { + return &provider.Response{ + ToolCalls: []provider.ToolCall{ + {ID: "tc-1", Name: "spy", Arguments: map[string]any{"original": "value"}}, + }, + }, nil + } + return &provider.Response{Content: "done"}, nil + }, + } + + var capturedToolArgs map[string]any + reg := tools.NewRegistry() + reg.Register(&simpleTool{ + name: "spy", + def: provider.ToolDef{Name: "spy", Description: "capture args"}, + fn: func(_ context.Context, args map[string]any) (any, error) { + capturedToolArgs = args + return "ok", nil + }, + }) + + cfg := Config{ + Provider: p, + ToolRegistry: reg, + OnEvent: func(e Event) { + // Mutate the event's ToolArgs — this should NOT affect tool execution. + if e.Type == EventToolCallStart && e.ToolArgs != nil { + e.ToolArgs["injected"] = "mutation" + delete(e.ToolArgs, "original") + } + }, + } + + _, err := Execute(context.Background(), cfg, "sys", "task", "agent-1") + if err != nil { + t.Fatalf("Execute: %v", err) + } + + // The tool should have received the original args, not the mutated ones. + if capturedToolArgs == nil { + t.Fatal("spy tool was not executed") + } + if _, mutated := capturedToolArgs["injected"]; mutated { + t.Error("tool received mutated args — ToolArgs on Event was not a copy") + } + if capturedToolArgs["original"] != "value" { + t.Errorf("tool args 'original' key: want %q, got %v", "value", capturedToolArgs["original"]) + } +} + +// TestExecute_OnEventPanicDoesNotCrashExecutor verifies that a panicking OnEvent +// callback does not abort the executor run. +func TestExecute_OnEventPanicDoesNotCrashExecutor(t *testing.T) { + p := &mockProvider{ + name: "mock", + chatResponse: &provider.Response{Content: "done"}, + } + cfg := Config{ + Provider: p, + OnEvent: func(e Event) { + panic("simulated observer panic") + }, + } + result, err := Execute(context.Background(), cfg, "sys", "task", "agent-1") + if err != nil { + t.Fatalf("Execute returned error: %v", err) + } + if result.Status != "completed" { + t.Errorf("Status: want %q, got %q", "completed", result.Status) + } +} + +// TestExecute_ShouldStopThinkingPopulated verifies that Result.Thinking is set +// when ShouldStop triggers an early exit. +func TestExecute_ShouldStopThinkingPopulated(t *testing.T) { + callN := 0 + p := &callCountProvider{ + onChat: func() (*provider.Response, error) { + callN++ + if callN == 1 { + return &provider.Response{ + Thinking: "internal reasoning", + ToolCalls: []provider.ToolCall{ + {ID: "tc-1", Name: "noop", Arguments: map[string]any{}}, + }, + }, nil + } + return &provider.Response{Content: "done"}, nil + }, + } + + reg := tools.NewRegistry() + reg.Register(&simpleTool{ + name: "noop", + def: provider.ToolDef{Name: "noop", Description: "does nothing"}, + fn: func(_ context.Context, _ map[string]any) (any, error) { return "ok", nil }, + }) + + result, err := Execute(context.Background(), Config{ + Provider: p, + ToolRegistry: reg, + ShouldStop: func() string { return "stop reason" }, + }, "sys", "task", "agent-1") + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result.Status != "completed" { + t.Errorf("Status: want %q, got %q", "completed", result.Status) + } + if result.Thinking != "internal reasoning" { + t.Errorf("Thinking: want %q, got %q", "internal reasoning", result.Thinking) + } +} + +// eventTypes extracts the Type from each event for diagnostic output. +func eventTypes(events []Event) []EventType { + out := make([]EventType, len(events)) + for i, e := range events { + out[i] = e.Type + } + return out +} + // TestExecute_ThinkingInResult verifies Result.Thinking is populated from provider response. func TestExecute_ThinkingInResult(t *testing.T) { p := &mockProvider{ diff --git a/executor/mesh_support_test.go b/executor/mesh_support_test.go new file mode 100644 index 0000000..a7e3589 --- /dev/null +++ b/executor/mesh_support_test.go @@ -0,0 +1,404 @@ +package executor + +import ( + "context" + "fmt" + "strings" + "sync" + "testing" + + "github.com/GoCodeAlone/workflow-plugin-agent/provider" + "github.com/GoCodeAlone/workflow-plugin-agent/tools" +) + +// TestMeshSupport_EndToEnd simulates a mesh scenario: a scripted provider, inbox +// channel with delayed messages, OnEvent collector, and ShouldStop after seeing +// a specific condition. It verifies inbox messages appear in conversation, events +// stream in correct order, and early termination works. +func TestMeshSupport_EndToEnd(t *testing.T) { + inbox := make(chan provider.Message, 5) + + var mu sync.Mutex + var events []Event + + // Track the number of tool calls for ShouldStop. + var toolCallCount int + var toolCallMu sync.Mutex + + // Create a tool that the agent will call. + reg := tools.NewRegistry() + reg.Register(&simpleTool{ + name: "status_update", + def: provider.ToolDef{Name: "status_update", Description: "update agent status"}, + fn: func(_ context.Context, args map[string]any) (any, error) { + toolCallMu.Lock() + toolCallCount++ + toolCallMu.Unlock() + return map[string]any{"updated": true, "status": args["status"]}, nil + }, + }) + + // Scripted provider: iteration 1 makes a tool call, iteration 2 makes a tool call, + // iteration 3 should never happen because ShouldStop triggers after iteration 2. + callN := 0 + var capturedMessages [][]provider.Message + p := &captureProvider{ + onChat: func(msgs []provider.Message) (*provider.Response, error) { + callN++ + mu.Lock() + capturedMessages = append(capturedMessages, append([]provider.Message{}, msgs...)) + mu.Unlock() + + switch callN { + case 1: + return &provider.Response{ + Content: "Starting work", + Thinking: "I need to update status first", + ToolCalls: []provider.ToolCall{ + {ID: "tc-1", Name: "status_update", Arguments: map[string]any{"status": "working"}}, + }, + }, nil + case 2: + return &provider.Response{ + Content: "Processing inbox message", + ToolCalls: []provider.ToolCall{ + {ID: "tc-2", Name: "status_update", Arguments: map[string]any{"status": "done"}}, + }, + }, nil + default: + return &provider.Response{Content: "Unexpected call"}, nil + } + }, + } + + cfg := Config{ + Provider: p, + ToolRegistry: reg, + MaxIterations: 10, + Inbox: inbox, + OnEvent: func(e Event) { + mu.Lock() + events = append(events, e) + mu.Unlock() + }, + ShouldStop: func() string { + toolCallMu.Lock() + defer toolCallMu.Unlock() + if toolCallCount >= 2 { + return "agent completed all work" + } + return "" + }, + } + + // Inject a message before execution to simulate mesh routing. The message + // is buffered and will be drained at the start of the first iteration. + inbox <- provider.Message{ + Role: provider.RoleUser, + Content: "Mesh message from Agent B: please finish up", + } + + result, err := Execute(context.Background(), cfg, "You are a mesh agent.", "Do the task.", "mesh-agent-1") + if err != nil { + t.Fatalf("Execute: %v", err) + } + + // 1. Verify early termination via ShouldStop. + if result.Status != "completed" { + t.Errorf("Status: want completed, got %q", result.Status) + } + if result.Content != "agent completed all work" { + t.Errorf("Content: want %q, got %q", "agent completed all work", result.Content) + } + if result.Iterations > 3 { + t.Errorf("Iterations: want <= 3, got %d", result.Iterations) + } + + // 2. Verify events were emitted in a reasonable order. + mu.Lock() + eventsCopy := append([]Event{}, events...) + mu.Unlock() + + if len(eventsCopy) == 0 { + t.Fatal("expected events to be emitted") + } + + // First event should be iteration. + if eventsCopy[0].Type != EventIteration { + t.Errorf("first event: want %q, got %q", EventIteration, eventsCopy[0].Type) + } + + // Last event should be completed (from ShouldStop). + lastEvent := eventsCopy[len(eventsCopy)-1] + if lastEvent.Type != EventCompleted { + t.Errorf("last event: want %q, got %q", EventCompleted, lastEvent.Type) + } + + // Should contain tool_call_start and tool_call_result events. + var hasToolStart, hasToolResult, hasThinking, hasText bool + for _, e := range eventsCopy { + switch e.Type { + case EventToolCallStart: + hasToolStart = true + case EventToolCallResult: + hasToolResult = true + case EventThinking: + hasThinking = true + case EventText: + hasText = true + } + } + if !hasToolStart { + t.Error("expected at least one tool_call_start event") + } + if !hasToolResult { + t.Error("expected at least one tool_call_result event") + } + if !hasThinking { + t.Error("expected at least one thinking event") + } + if !hasText { + t.Error("expected at least one text event") + } + + // 3. Verify the inbox message appeared in the conversation at some point. + mu.Lock() + capturedCopy := capturedMessages + mu.Unlock() + + inboxMsgFound := false + for _, msgs := range capturedCopy { + for _, msg := range msgs { + if strings.Contains(msg.Content, "Mesh message from Agent B") { + inboxMsgFound = true + break + } + } + if inboxMsgFound { + break + } + } + if !inboxMsgFound { + t.Error("expected inbox message from Agent B to appear in conversation") + } +} + +// TestMeshSupport_InboxClosedMidExecution verifies the executor handles a closed inbox gracefully. +func TestMeshSupport_InboxClosedMidExecution(t *testing.T) { + inbox := make(chan provider.Message, 2) + inbox <- provider.Message{Role: provider.RoleUser, Content: "last message"} + close(inbox) + + p := &mockProvider{ + name: "mock", + chatResponse: &provider.Response{Content: "ok"}, + } + + result, err := Execute(context.Background(), Config{ + Provider: p, + Inbox: inbox, + }, "sys", "task", "agent-1") + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result.Status != "completed" { + t.Errorf("Status: want completed, got %q", result.Status) + } +} + +// TestMeshSupport_EmptyInbox verifies that an empty inbox doesn't affect execution. +func TestMeshSupport_EmptyInbox(t *testing.T) { + inbox := make(chan provider.Message, 5) // Empty. + + p := &mockProvider{ + name: "mock", + chatResponse: &provider.Response{Content: "ok"}, + } + + result, err := Execute(context.Background(), Config{ + Provider: p, + Inbox: inbox, + }, "sys", "task", "agent-1") + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result.Status != "completed" { + t.Errorf("Status: want completed, got %q", result.Status) + } +} + +// TestMeshSupport_ShouldStopWithNoToolCalls verifies ShouldStop isn't called when +// there are no tool calls (the loop naturally exits via the break). +func TestMeshSupport_ShouldStopWithNoToolCalls(t *testing.T) { + p := &mockProvider{ + name: "mock", + chatResponse: &provider.Response{Content: "immediate answer"}, + } + + shouldStopCalled := false + result, err := Execute(context.Background(), Config{ + Provider: p, + ShouldStop: func() string { + shouldStopCalled = true + return "should not trigger" + }, + }, "sys", "task", "agent-1") + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result.Status != "completed" { + t.Errorf("Status: want completed, got %q", result.Status) + } + if result.Content != "immediate answer" { + t.Errorf("Content: want %q, got %q", "immediate answer", result.Content) + } + if shouldStopCalled { + t.Error("ShouldStop should not be called when there are no tool calls") + } +} + +// TestMeshSupport_EventAgentIDPropagation verifies AgentID is set on all events. +func TestMeshSupport_EventAgentIDPropagation(t *testing.T) { + p := &mockProvider{ + name: "mock", + chatResponse: &provider.Response{Content: "done"}, + } + + var events []Event + _, err := Execute(context.Background(), Config{ + Provider: p, + OnEvent: func(e Event) { + events = append(events, e) + }, + }, "sys", "task", "my-agent-42") + if err != nil { + t.Fatalf("Execute: %v", err) + } + + for i, e := range events { + if e.AgentID != "my-agent-42" { + t.Errorf("event[%d] (type=%s): AgentID want %q, got %q", i, e.Type, "my-agent-42", e.AgentID) + } + } +} + +// TestMeshSupport_MultipleInboxMessages verifies multiple inbox messages are all drained. +func TestMeshSupport_MultipleInboxMessages(t *testing.T) { + inbox := make(chan provider.Message, 5) + inbox <- provider.Message{Role: provider.RoleUser, Content: "msg-1-from-mesh"} + inbox <- provider.Message{Role: provider.RoleUser, Content: "msg-2-from-mesh"} + inbox <- provider.Message{Role: provider.RoleUser, Content: "msg-3-from-mesh"} + + var capturedMessages []provider.Message + p := &captureProvider{ + onChat: func(msgs []provider.Message) (*provider.Response, error) { + capturedMessages = msgs + return &provider.Response{Content: "ok"}, nil + }, + } + + result, err := Execute(context.Background(), Config{ + Provider: p, + Inbox: inbox, + }, "sys", "task", "agent-1") + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result.Status != "completed" { + t.Errorf("Status: want completed, got %q", result.Status) + } + + // All three messages should appear. + foundCount := 0 + for _, msg := range capturedMessages { + if strings.HasPrefix(msg.Content, "msg-") && strings.HasSuffix(msg.Content, "-from-mesh") { + foundCount++ + } + } + if foundCount != 3 { + t.Errorf("expected 3 inbox messages in conversation, found %d", foundCount) + } +} + +// TestMeshSupport_InboxTranscriptRecording verifies inbox messages are recorded in the transcript. +func TestMeshSupport_InboxTranscriptRecording(t *testing.T) { + inbox := make(chan provider.Message, 1) + inbox <- provider.Message{Role: provider.RoleUser, Content: "external-mesh-msg"} + + p := &mockProvider{ + name: "mock", + chatResponse: &provider.Response{Content: "ok"}, + } + + recorder := &recordingTranscript{} + _, err := Execute(context.Background(), Config{ + Provider: p, + Inbox: inbox, + Transcript: recorder, + }, "sys", "task", "agent-1") + if err != nil { + t.Fatalf("Execute: %v", err) + } + + found := false + for _, entry := range recorder.entries { + if entry.Content == "external-mesh-msg" { + found = true + break + } + } + if !found { + t.Error("expected inbox message to be recorded in transcript") + } +} + +// TestMeshSupport_ShouldStopReasonEmptyNoStop verifies ShouldStop returning empty string does not stop. +func TestMeshSupport_ShouldStopReasonEmptyNoStop(t *testing.T) { + callN := 0 + p := &callCountProvider{ + onChat: func() (*provider.Response, error) { + callN++ + if callN <= 3 { + return &provider.Response{ + ToolCalls: []provider.ToolCall{ + {ID: fmt.Sprintf("tc-%d", callN), Name: "noop", Arguments: map[string]any{"n": callN}}, + }, + }, nil + } + return &provider.Response{Content: "final"}, nil + }, + } + + reg := tools.NewRegistry() + reg.Register(&simpleTool{ + name: "noop", + def: provider.ToolDef{Name: "noop", Description: "no-op"}, + fn: func(_ context.Context, _ map[string]any) (any, error) { + return "ok", nil + }, + }) + + shouldStopCalls := 0 + result, err := Execute(context.Background(), Config{ + Provider: p, + ToolRegistry: reg, + MaxIterations: 10, + ShouldStop: func() string { + shouldStopCalls++ + return "" // Never stop. + }, + }, "sys", "task", "agent-1") + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result.Status != "completed" { + t.Errorf("Status: want completed, got %q", result.Status) + } + if result.Content != "final" { + t.Errorf("Content: want %q, got %q", "final", result.Content) + } + // ShouldStop should have been called for each iteration with tool calls (3 times). + if shouldStopCalls != 3 { + t.Errorf("ShouldStop calls: want 3, got %d", shouldStopCalls) + } +} diff --git a/go.mod b/go.mod index cb88bd8..0ca6d67 100644 --- a/go.mod +++ b/go.mod @@ -5,14 +5,21 @@ go 1.26.0 require ( github.com/GoCodeAlone/modular v1.12.3 github.com/GoCodeAlone/workflow v0.3.56 + github.com/GoCodeAlone/workflow-plugin-authz v0.2.2 github.com/anthropics/anthropic-sdk-go v1.26.0 github.com/aws/aws-sdk-go-v2 v1.41.4 github.com/aws/aws-sdk-go-v2/credentials v1.19.12 + github.com/docker/docker v28.5.2+incompatible + github.com/go-rod/rod v0.116.2 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/generative-ai-go v0.20.1 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 + github.com/ollama/ollama v0.18.3 github.com/openai/openai-go v1.12.0 + golang.org/x/crypto v0.48.0 golang.org/x/oauth2 v0.36.0 + golang.org/x/text v0.34.0 google.golang.org/api v0.271.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.45.0 @@ -41,7 +48,6 @@ require ( github.com/GoCodeAlone/modular/modules/jsonschema v1.14.0 // indirect github.com/GoCodeAlone/modular/modules/reverseproxy/v2 v2.7.0 // indirect github.com/GoCodeAlone/modular/modules/scheduler v1.14.0 // indirect - github.com/GoCodeAlone/workflow-plugin-authz v0.2.2 // indirect github.com/GoCodeAlone/yaegi v0.17.1 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect @@ -96,8 +102,6 @@ require ( github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/digitalocean/godo v1.175.0 // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/dlclark/regexp2 v1.11.4 // indirect - github.com/docker/docker v28.5.2+incompatible // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect @@ -113,10 +117,8 @@ require ( github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-rod/rod v0.116.2 // indirect github.com/go-sql-driver/mysql v1.7.1 // indirect github.com/gobwas/glob v0.2.3 // indirect - github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golobby/cast v1.3.3 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/go-querystring v1.1.0 // indirect @@ -176,7 +178,6 @@ require ( github.com/nats-io/nuid v1.0.1 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/oklog/run v1.2.0 // indirect - github.com/ollama/ollama v0.18.3 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pierrec/lz4/v4 v4.1.25 // indirect @@ -233,13 +234,11 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect golang.org/x/mod v0.33.0 // indirect golang.org/x/net v0.51.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.42.0 // indirect google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect diff --git a/go.sum b/go.sum index 947cca4..87fb36e 100644 --- a/go.sum +++ b/go.sum @@ -169,6 +169,8 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bufbuild/protocompile v0.10.0 h1:+jW/wnLMLxaCEG8AX9lD0bQ5v9h1RUiMKOBOT5ll9dM= +github.com/bufbuild/protocompile v0.10.0/go.mod h1:G9qQIQo0xZ6Uyj6CMNz0saGmx2so+KONo8/KrELABiY= github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk= @@ -327,6 +329,7 @@ github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69 github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -456,6 +459,8 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6 github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= +github.com/jhump/protoreflect v1.16.0 h1:54fZg+49widqXYQ0b+usAFHbMkBGR4PpXrsHc8+TBDg= +github.com/jhump/protoreflect v1.16.0/go.mod h1:oYPd7nPvcBw/5wlDfm/AVmU9zH9BgqGCI469pGxfj/8= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -716,8 +721,11 @@ github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ= github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns= github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ= github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18= +github.com/ysmood/gop v0.2.0 h1:+tFrG0TWPxT6p9ZaZs+VY+opCvHU8/3Fk6BaNv6kqKg= +github.com/ysmood/gop v0.2.0/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk= github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q= github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg= +github.com/ysmood/gotrace v0.6.0 h1:SyI1d4jclswLhg7SWTL6os3L1WOKeNn/ZtzVQF8QmdY= github.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM= github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE= github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg= diff --git a/provider/llama_cpp_download_test.go b/provider/llama_cpp_download_test.go index 881f325..93a3310 100644 --- a/provider/llama_cpp_download_test.go +++ b/provider/llama_cpp_download_test.go @@ -38,11 +38,11 @@ func TestAssetNameForPlatform_AllPlatforms(t *testing.T) { goarch string want string }{ - {"darwin", "arm64", "-bin-macos-arm64.zip"}, - {"darwin", "amd64", "-bin-macos-x64.zip"}, + {"darwin", "arm64", "-bin-macos-arm64.tar.gz"}, + {"darwin", "amd64", "-bin-macos-x64.tar.gz"}, {"windows", "amd64", "-bin-win-avx-x64.zip"}, - {"linux", "amd64", "-bin-linux-amd64.zip"}, - {"linux", "arm64", "-bin-linux-arm64.zip"}, + {"linux", "amd64", "-bin-linux-amd64.tar.gz"}, + {"linux", "arm64", "-bin-linux-arm64.tar.gz"}, } for _, tc := range cases { got := assetNameForPlatform("b3447", tc.goos, tc.goarch)