Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 77da7f2e46 | |||
| 42080b6d6d | |||
| c3df8b833d | |||
| 8694e3a20c | |||
| 9def03d4e3 | |||
| bd7625f947 | |||
| 0a547fbe17 | |||
| 331e3f32bd | |||
| 7db1d986ab | |||
| 6042a6c5f5 | |||
| bff916a3ac | |||
| b8c03dafa2 | |||
| 04bcef47ac | |||
| e92978f3eb | |||
| 695a24432a | |||
| 2889b98dcf | |||
| a356ab36a8 | |||
| 2078748e7f | |||
| d283f9b08b | |||
| af7b4f3d50 | |||
| 954f8fa79e | |||
| cdfe9c0947 | |||
| c645c5faa7 | |||
| 8bde01bdac | |||
| 1373ceec21 | |||
| 838b7f73f8 | |||
| c56423a33c | |||
| 6edc0de7bd | |||
| 3d31fe37db | |||
| 6840ae0fb1 | |||
| af61d6327a |
@@ -21,12 +21,10 @@ changes, architectural rewrites. Those go to the human.
|
||||
|
||||
## Work queue (ranked)
|
||||
|
||||
1. **Surface AtlasCloud tool results when the follow-up reply is empty** ([#3735](https://github.com/micro/go-micro/issues/3735)) — PR #3738 closed the prior A2A streaming fallback blocker (#3560), and the latest live provider run exposed the next adoption-critical Now-phase conformance seam: AtlasCloud can execute the tool but return an empty assistant follow-up, leaving first-agent/provider conformance with a blank reply instead of the deterministic tool result. Fixing this keeps the service-as-tool story dependable before chasing deeper harness parser work.
|
||||
2. **Require AtlasCloud notification side effects in multi-step harnesses** ([#3736](https://github.com/micro/go-micro/issues/3736)) — after the basic tool-result path is reliable, the multi-step universe and plan/delegate harnesses must not accept provider replies that claim completion while skipping the required notify/delegate side effect. This protects the services → agents → workflows lifecycle promised by the first-agent and 0→hero paths: an agent should operate the system, not only narrate success.
|
||||
3. **Parse multi-event A2A SSE fallback responses in the harness** ([#3662](https://github.com/micro/go-micro/issues/3662)) — once the current AtlasCloud tool/side-effect failures are resolved, the harness must accept legitimate multi-event `message/stream` responses instead of concatenating valid SSE events into invalid JSON. This remains a small CI-verifiable harness fix that keeps cross-provider streaming conformance focused on real gateway failures rather than parser brittleness.
|
||||
4. **Propagate agent run cancellation and deadlines through model and tool calls** ([#3544](https://github.com/micro/go-micro/issues/3544)) — the highest-value remaining resilience gap is predictable failure semantics across agent runs, model calls, tool calls, plan/delegate, and flow handoffs. Tool retries, live-provider deadline tuning, and delegated-plan completion are in place; the lifecycle still needs cancellation/deadline propagation so work fails safely instead of becoming opaque loops.
|
||||
5. **Emit OpenTelemetry spans for agent run timelines** ([#3525](https://github.com/micro/go-micro/issues/3525)) — recent work made runs inspectable, correlated trace metadata through scheduled dispatch, verified restart resume, added opt-in tool retries, hardened provider conformance, and fixed provider-emitted text tool calls. The next Next-phase step is to turn that RunInfo foundation into standard OTel spans for agent runs, model calls, tool calls, checkpoint/resume, cancellation/deadlines, and failures.
|
||||
6. **Add an AP2 mandate layer over A2A and x402** ([#3552](https://github.com/micro/go-micro/issues/3552)) — this is a forward interop investment, not a Now-phase blocker: Go Micro already has A2A agents and x402 paid tools, so a small signed-mandate foundation can keep agent payments aligned with the open-protocol story without pulling the queue away from adoption, resilience, or observability. Keep it additive and opt-in while the AP2/FIDO work settles.
|
||||
1. **Stabilize atlascloud plan-delegate harness timeout** ([#3826](https://github.com/micro/go-micro/issues/3826)) — #3828 closed the file-store expiry blocker, so the remaining red evaluator risk is the live-provider plan/delegate timeout where atlascloud returned an opaque RPC 408 before delegated task or notification side effects were observed. Keep this first because the loop's adoption contract depends on the same services → agents → workflows harness proving the first-agent and 0→hero paths are reliable, and failures must be classified well enough for developers and triage to act.
|
||||
2. **Propagate agent run cancellation and deadlines through model and tool calls** ([#3544](https://github.com/micro/go-micro/issues/3544)) — once the remaining harness timeout is classified or stabilized, the highest-value Now-phase resilience gap is predictable failure semantics across agent runs, model calls, tool calls, plan/delegate, and flow handoffs. Tool retries, live-provider deadline tuning, delegated-plan completion, side-effect enforcement, and file-store isolation are in place; the lifecycle still needs cancellation/deadline propagation so work fails safely instead of becoming opaque loops.
|
||||
3. **Emit OpenTelemetry spans for agent run timelines** ([#3525](https://github.com/micro/go-micro/issues/3525)) — recent work made runs inspectable, correlated trace metadata through scheduled dispatch, verified restart resume, added opt-in tool retries, hardened provider conformance, fixed provider-emitted text tool calls, and narrowed the latest provider timeout to a diagnosability gap. The next Next-phase step is to turn that RunInfo foundation into standard OTel spans for agent runs, model calls, tool calls, checkpoint/resume, cancellation/deadlines, and failures.
|
||||
4. **Add an AP2 mandate layer over A2A and x402** ([#3552](https://github.com/micro/go-micro/issues/3552)) — this is a forward interop investment, not a Now-phase blocker: Go Micro already has A2A agents and x402 paid tools, so a small signed-mandate foundation can keep agent payments aligned with the open-protocol story without pulling the queue away from adoption, resilience, or observability. Keep it additive and opt-in while the AP2/FIDO work settles.
|
||||
|
||||
_Seeded by Claude Code from the roadmap + open issues; thereafter maintained by the
|
||||
architecture-review pass._
|
||||
|
||||
@@ -18,7 +18,7 @@ on:
|
||||
providers:
|
||||
description: "Comma-separated providers for live conformance (default: all supported)"
|
||||
required: false
|
||||
default: "anthropic,openai,gemini,groq,mistral,together,atlascloud"
|
||||
default: "anthropic,openai,gemini,groq,minimax,mistral,together,atlascloud"
|
||||
harnesses:
|
||||
description: "Comma-separated harnesses for live conformance"
|
||||
required: false
|
||||
@@ -64,6 +64,7 @@ jobs:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
|
||||
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
|
||||
ATLASCLOUD_API_KEY: ${{ secrets.ATLASCLOUD_API_KEY }}
|
||||
@@ -73,7 +74,7 @@ jobs:
|
||||
# catalog id differs (Atlas uses org/model ids).
|
||||
ATLASCLOUD_MODEL: ${{ vars.ATLASCLOUD_MODEL || 'minimaxai/minimax-m3' }}
|
||||
run: |
|
||||
PROVIDERS="${{ github.event.inputs.providers || 'anthropic,openai,gemini,groq,mistral,together,atlascloud' }}"
|
||||
PROVIDERS="${{ github.event.inputs.providers || 'anthropic,openai,gemini,groq,minimax,mistral,together,atlascloud' }}"
|
||||
HARNESSES="${{ github.event.inputs.harnesses || 'agent,universe,agent-flow,plan-delegate,a2a-stream-fallback' }}"
|
||||
REQUIRE_CONFIGURED="${{ github.event.inputs.require_configured || 'false' }}"
|
||||
|
||||
|
||||
@@ -16,9 +16,30 @@ next version when it ships.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **MiniMax provider** — run agents against MiniMax's `MiniMax-M3` model via its OpenAI-compatible endpoint, with tool calling and streaming; auto-detected from the base URL. (`ai/minimax/`)
|
||||
|
||||
### Fixed
|
||||
- **Plan/delegate completion** — agents now continue unfinished plan steps more reliably, fail checkpointed runs that leave delegated plans unfinished, recover from unknown plan-delegate tool calls, avoid duplicate side effects, and complete timeout paths deterministically. (`agent/`)
|
||||
- **AtlasCloud tool calls** — streaming and request fallback handling now recovers tool-call results from provider responses that omit the expected structured fields. (`ai/atlascloud/`)
|
||||
|
||||
---
|
||||
|
||||
## [6.3.13] - July 2026
|
||||
|
||||
### Added
|
||||
- **`micro loop`** — scaffold an autonomous improvement loop into any repository: GitHub Actions workflows dispatched to an @mention-driven coding agent, across up to five roles — `planner` (ranked queue), `builder` (top item as a single-concern PR, auto-merged on green CI), `triage` (CI failures → fix issues), and opt-in `coherence` (docs/CHANGELOG alignment) and `release` (daily patch tag). Each dispatch role's instruction lives in an editable `.github/loop/prompts/<role>.md` file — the workflow is the mechanism, the prompt is the policy — so a repo customizes behavior without forking the CLI. `micro loop init --roles …` writes it all; `micro loop verify` checks the wiring. This is the loop that maintains go-micro itself, generalized. (`cmd/micro/loop/`)
|
||||
|
||||
### Changed
|
||||
- **x402 payments** — settlement now covers CDP facilitator authentication and conformance edge cases. (`wrapper/x402/`)
|
||||
|
||||
### Fixed
|
||||
- **Plan/delegate harnessing** — side effects and notifications are now idempotent and deterministic across duplicate, alias, order-scoped, and reachability scenarios. (`agent/`, `internal/harness/`)
|
||||
|
||||
### Documentation
|
||||
- **First-agent on-ramp** — quickstart docs now connect the no-secret first-agent transcript, example map, and 0→hero path. (`README.md`, `internal/website/docs/`)
|
||||
- **Ollama provider docs** — the provider surface, capability matrix, and examples now document local and cloud behavior. (`internal/website/docs/`, `examples/agent-ollama/`)
|
||||
|
||||
---
|
||||
|
||||
## [6.3.12] - July 2026
|
||||
|
||||
@@ -332,7 +332,7 @@ MCP exposes your services as tools; A2A exposes your agents as agents. See the [
|
||||
| MCP gateway | Every endpoint is an AI tool automatically |
|
||||
| A2A gateway | Every agent is reachable over the Agent2Agent protocol; cards generated from the registry (`micro a2a`) |
|
||||
| Payments (x402) | Opt-in per-call payments for tools via the x402 standard; pluggable facilitator (Base, Solana, …) |
|
||||
| 8 LLM providers | Anthropic, OpenAI, Gemini, Groq, Mistral, Together, Atlas Cloud, Ollama (local + cloud) |
|
||||
| 9 LLM providers | Anthropic, OpenAI, Gemini, Groq, Mistral, Together, Atlas Cloud, MiniMax, Ollama (local + cloud) |
|
||||
| Interactive console | `micro run` includes a chat console for talking to services |
|
||||
| Service generation | `micro run --prompt` — describe a system, get running services |
|
||||
|
||||
@@ -429,6 +429,7 @@ Swap providers with a single import — same interface everywhere:
|
||||
| Mistral | `mistral-large-latest` |
|
||||
| Together AI | `meta-llama/Llama-3.3-70B-Instruct-Turbo` |
|
||||
| Atlas Cloud | `deepseek-ai/DeepSeek-V3-0324` |
|
||||
| MiniMax | `MiniMax-M3` |
|
||||
| Ollama | `llama3.2` (local) |
|
||||
|
||||
```go
|
||||
|
||||
@@ -300,6 +300,20 @@ Default base URL: `https://api.atlascloud.ai`
|
||||
|
||||
Atlas Cloud is an enterprise AI infrastructure platform offering high-performance LLM APIs. It exposes an OpenAI-compatible chat completions endpoint with tool calling support.
|
||||
|
||||
### MiniMax
|
||||
|
||||
```go
|
||||
m := ai.New("minimax",
|
||||
ai.WithAPIKey("your-key"),
|
||||
ai.WithModel("MiniMax-M3"), // default
|
||||
)
|
||||
```
|
||||
|
||||
Default model: `MiniMax-M3`
|
||||
Default base URL: `https://api.minimax.io`
|
||||
|
||||
MiniMax offers its flagship MiniMax-M3 model via an OpenAI-compatible chat completions endpoint.
|
||||
|
||||
## Auto-Detection
|
||||
|
||||
Use `AutoDetectProvider()` to detect the provider from a base URL:
|
||||
|
||||
+76
-14
@@ -52,6 +52,15 @@ type Provider struct {
|
||||
opts ai.Options
|
||||
}
|
||||
|
||||
type atlasToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
} `json:"function"`
|
||||
}
|
||||
|
||||
// NewProvider creates a new Atlas Cloud provider.
|
||||
func NewProvider(opts ...ai.Option) *Provider {
|
||||
options := ai.NewOptions(opts...)
|
||||
@@ -121,7 +130,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
apiReq["tools"] = tools
|
||||
}
|
||||
|
||||
resp, rawMessage, err := p.callAPI(ctx, apiReq)
|
||||
resp, rawMessage, err := p.callAPI(ctx, "chat", apiReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -155,8 +164,11 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
"messages": followUpMessages,
|
||||
}
|
||||
|
||||
followUpResp, _, err := p.callAPI(ctx, followUpReq)
|
||||
if err == nil && followUpResp.Reply != "" {
|
||||
followUpResp, _, err := p.callAPI(ctx, "tool-follow-up", followUpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if followUpResp.Reply != "" {
|
||||
resp.Answer = followUpResp.Reply
|
||||
} else if len(toolResults) > 0 {
|
||||
resp.Answer = strings.Join(toolResults, "\n")
|
||||
@@ -277,7 +289,7 @@ func (s *atlasStream) Close() error {
|
||||
return s.body.Close()
|
||||
}
|
||||
|
||||
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
|
||||
func (p *Provider) callAPI(ctx context.Context, phase string, req map[string]any) (*ai.Response, map[string]any, error) {
|
||||
reqBody, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
@@ -300,20 +312,14 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
|
||||
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
return nil, nil, fmt.Errorf("API error (%s) during atlascloud %s request (%s): %s", httpResp.Status, phase, atlascloudRequestSummary(req), string(respBody))
|
||||
}
|
||||
|
||||
var chatResp struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
ToolCalls []struct {
|
||||
ID string `json:"id"`
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
} `json:"function"`
|
||||
} `json:"tool_calls"`
|
||||
Content string `json:"content"`
|
||||
ToolCalls []atlasToolCall `json:"tool_calls"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
@@ -345,12 +351,68 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
|
||||
|
||||
rawMessage := map[string]any{
|
||||
"content": choice.Message.Content,
|
||||
"tool_calls": choice.Message.ToolCalls,
|
||||
"tool_calls": normalizeAtlasCloudToolCalls(choice.Message.ToolCalls),
|
||||
}
|
||||
|
||||
return response, rawMessage, nil
|
||||
}
|
||||
|
||||
func normalizeAtlasCloudToolCalls(toolCalls []atlasToolCall) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(toolCalls))
|
||||
for _, tc := range toolCalls {
|
||||
toolType := tc.Type
|
||||
if toolType == "" {
|
||||
toolType = "function"
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"id": tc.ID,
|
||||
"type": toolType,
|
||||
"function": map[string]any{
|
||||
"name": tc.Function.Name,
|
||||
"arguments": tc.Function.Arguments,
|
||||
},
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func atlascloudRequestSummary(req map[string]any) string {
|
||||
parts := []string{}
|
||||
if model, ok := req["model"].(string); ok && model != "" {
|
||||
parts = append(parts, "model="+model)
|
||||
}
|
||||
if messages, ok := req["messages"].([]map[string]any); ok {
|
||||
parts = append(parts, fmt.Sprintf("messages=%d", len(messages)))
|
||||
if len(messages) > 0 {
|
||||
last := messages[len(messages)-1]
|
||||
if role, ok := last["role"].(string); ok && role != "" {
|
||||
parts = append(parts, "last_role="+role)
|
||||
}
|
||||
if _, ok := last["tool_call_id"].(string); ok {
|
||||
parts = append(parts, "last_has_tool_call_id=true")
|
||||
}
|
||||
}
|
||||
}
|
||||
if tools, ok := req["tools"].([]map[string]any); ok {
|
||||
names := make([]string, 0, len(tools))
|
||||
for _, tool := range tools {
|
||||
fn, _ := tool["function"].(map[string]any)
|
||||
name, _ := fn["name"].(string)
|
||||
if name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("tools=%d", len(tools)))
|
||||
if len(names) > 0 {
|
||||
parts = append(parts, "tool_names="+strings.Join(names, ","))
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "request_context=unavailable"
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
const defaultImageModel = "openai/gpt-image-2/text-to-image"
|
||||
|
||||
// GenerateImage creates an image using Atlas Cloud's async image API.
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
@@ -203,6 +204,124 @@ func TestProvider_GenerateToolCallEmptyFollowUpUsesToolResult(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_GenerateMinimaxToolRequests(t *testing.T) {
|
||||
var bodies []map[string]any
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
bodies = append(bodies, body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch len(bodies) {
|
||||
case 1:
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"","tool_calls":[{"id":"call-1","function":{"name":"conformance_echo","arguments":"{\"value\":\"agent-conformance\"}"}}]}}]}`))
|
||||
case 2:
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"done"}}]}`))
|
||||
default:
|
||||
t.Fatalf("unexpected API call %d", len(bodies))
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
p := NewProvider(
|
||||
ai.WithAPIKey("test-key"),
|
||||
ai.WithBaseURL(ts.URL),
|
||||
ai.WithModel("minimaxai/minimax-m3"),
|
||||
ai.WithToolHandler(func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
return ai.ToolResult{ID: call.ID, Content: `{"marker":"agent-conformance-ok"}`}
|
||||
}),
|
||||
)
|
||||
resp, err := p.Generate(context.Background(), &ai.Request{
|
||||
SystemPrompt: "You are helpful.",
|
||||
Prompt: "call a tool",
|
||||
Tools: []ai.Tool{{
|
||||
Name: "conformance_echo",
|
||||
Description: "echo conformance marker",
|
||||
Properties: map[string]any{"value": map[string]any{"type": "string"}},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate returned error: %v", err)
|
||||
}
|
||||
if resp.Answer != "done" {
|
||||
t.Fatalf("Answer = %q, want done", resp.Answer)
|
||||
}
|
||||
if len(bodies) != 2 {
|
||||
t.Fatalf("captured requests = %d, want 2", len(bodies))
|
||||
}
|
||||
if got := bodies[0]["model"]; got != "minimaxai/minimax-m3" {
|
||||
t.Fatalf("initial model = %v", got)
|
||||
}
|
||||
tools, ok := bodies[0]["tools"].([]any)
|
||||
if !ok || len(tools) != 1 {
|
||||
t.Fatalf("initial tools = %#v, want one tool", bodies[0]["tools"])
|
||||
}
|
||||
tool := tools[0].(map[string]any)
|
||||
if tool["type"] != "function" {
|
||||
t.Fatalf("tool type = %v, want function", tool["type"])
|
||||
}
|
||||
fn := tool["function"].(map[string]any)
|
||||
if fn["name"] != "conformance_echo" {
|
||||
t.Fatalf("tool function name = %v", fn["name"])
|
||||
}
|
||||
params := fn["parameters"].(map[string]any)
|
||||
if params["type"] != "object" {
|
||||
t.Fatalf("parameters type = %v, want object", params["type"])
|
||||
}
|
||||
|
||||
followUpMessages := bodies[1]["messages"].([]any)
|
||||
if len(followUpMessages) != 4 {
|
||||
t.Fatalf("follow-up messages = %d, want 4", len(followUpMessages))
|
||||
}
|
||||
assistant := followUpMessages[2].(map[string]any)
|
||||
if assistant["role"] != "assistant" {
|
||||
t.Fatalf("assistant role = %v", assistant["role"])
|
||||
}
|
||||
assistantCalls := assistant["tool_calls"].([]any)
|
||||
assistantCall := assistantCalls[0].(map[string]any)
|
||||
if assistantCall["type"] != "function" {
|
||||
t.Fatalf("assistant tool call type = %v, want function", assistantCall["type"])
|
||||
}
|
||||
toolResult := followUpMessages[3].(map[string]any)
|
||||
if toolResult["role"] != "tool" || toolResult["tool_call_id"] != "call-1" {
|
||||
t.Fatalf("tool result message = %#v", toolResult)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_GenerateToolCallHTTPErrorIncludesRequestContext(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"code":400,"msg":"bad request"}`, http.StatusBadRequest)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
p := NewProvider(
|
||||
ai.WithAPIKey("test-key"),
|
||||
ai.WithBaseURL(ts.URL),
|
||||
ai.WithModel("minimaxai/minimax-m3"),
|
||||
)
|
||||
_, err := p.Generate(context.Background(), &ai.Request{
|
||||
Prompt: "call a tool",
|
||||
Tools: []ai.Tool{{
|
||||
Name: "conformance_echo",
|
||||
Description: "echo conformance marker",
|
||||
Properties: map[string]any{"value": map[string]any{"type": "string"}},
|
||||
}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Generate error = nil, want 400")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{"400 Bad Request", "atlascloud chat request", "model=minimaxai/minimax-m3", "tools=1", "tool_names=conformance_echo"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("error %q missing %q", msg, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(msg, "test-key") {
|
||||
t.Fatalf("error leaked API key: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Registration(t *testing.T) {
|
||||
m := ai.New("atlascloud", ai.WithAPIKey("test"))
|
||||
if m == nil {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
_ "go-micro.dev/v6/ai/atlascloud"
|
||||
_ "go-micro.dev/v6/ai/gemini"
|
||||
_ "go-micro.dev/v6/ai/groq"
|
||||
_ "go-micro.dev/v6/ai/minimax"
|
||||
_ "go-micro.dev/v6/ai/mistral"
|
||||
_ "go-micro.dev/v6/ai/openai"
|
||||
_ "go-micro.dev/v6/ai/together"
|
||||
@@ -16,7 +17,7 @@ import (
|
||||
|
||||
func TestRegisteredProviders(t *testing.T) {
|
||||
got := ai.RegisteredProviders("")
|
||||
want := []string{"anthropic", "atlascloud", "gemini", "groq", "mistral", "openai", "together"}
|
||||
want := []string{"anthropic", "atlascloud", "gemini", "groq", "minimax", "mistral", "openai", "together"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("RegisteredProviders() = %#v, want %#v", got, want)
|
||||
}
|
||||
@@ -34,7 +35,7 @@ func TestRegisteredProviders(t *testing.T) {
|
||||
}
|
||||
|
||||
got = ai.RegisteredProviders("stream")
|
||||
want = []string{"atlascloud", "groq", "mistral", "openai", "together"}
|
||||
want = []string{"atlascloud", "groq", "minimax", "mistral", "openai", "together"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("RegisteredProviders(stream) = %#v, want %#v", got, want)
|
||||
}
|
||||
@@ -47,6 +48,7 @@ func TestCapabilityRows(t *testing.T) {
|
||||
{Provider: "atlascloud", Capabilities: ai.Capabilities{Model: true, Image: true, Video: true, Stream: true}},
|
||||
{Provider: "gemini", Capabilities: ai.Capabilities{Model: true}},
|
||||
{Provider: "groq", Capabilities: ai.Capabilities{Model: true, Stream: true}},
|
||||
{Provider: "minimax", Capabilities: ai.Capabilities{Model: true, Stream: true}},
|
||||
{Provider: "mistral", Capabilities: ai.Capabilities{Model: true, Stream: true}},
|
||||
{Provider: "openai", Capabilities: ai.Capabilities{Model: true, Image: true, Stream: true}},
|
||||
{Provider: "together", Capabilities: ai.Capabilities{Model: true, Stream: true}},
|
||||
@@ -59,7 +61,7 @@ func TestCapabilityRows(t *testing.T) {
|
||||
func TestCapabilityMatrix(t *testing.T) {
|
||||
matrix := ai.CapabilityMatrix()
|
||||
|
||||
for _, provider := range []string{"anthropic", "atlascloud", "gemini", "groq", "mistral", "openai", "together"} {
|
||||
for _, provider := range []string{"anthropic", "atlascloud", "gemini", "groq", "minimax", "mistral", "openai", "together"} {
|
||||
caps, ok := matrix[provider]
|
||||
if !ok {
|
||||
t.Fatalf("CapabilityMatrix missing %q", provider)
|
||||
@@ -88,7 +90,7 @@ func TestRegisterStream(t *testing.T) {
|
||||
}
|
||||
|
||||
got := ai.RegisteredProviders("stream")
|
||||
want := []string{"atlascloud", "groq", "mistral", "openai", "test-stream", "together"}
|
||||
want := []string{"atlascloud", "groq", "minimax", "mistral", "openai", "test-stream", "together"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("RegisteredProviders(stream) = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
// Package minimax implements the MiniMax model provider.
|
||||
//
|
||||
// MiniMax offers its flagship MiniMax-M3 model via an OpenAI-compatible
|
||||
// chat completions endpoint.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// import _ "go-micro.dev/v6/ai/minimax"
|
||||
//
|
||||
// m := ai.New("minimax",
|
||||
// ai.WithAPIKey("your-api-key"),
|
||||
// )
|
||||
package minimax
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/ai/internal/openaiapi"
|
||||
)
|
||||
|
||||
func init() {
|
||||
ai.Register("minimax", func(opts ...ai.Option) ai.Model {
|
||||
return NewProvider(opts...)
|
||||
})
|
||||
ai.RegisterStream("minimax")
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
opts ai.Options
|
||||
}
|
||||
|
||||
func NewProvider(opts ...ai.Option) *Provider {
|
||||
options := ai.NewOptions(opts...)
|
||||
if options.Model == "" {
|
||||
options.Model = "MiniMax-M3"
|
||||
}
|
||||
if options.BaseURL == "" {
|
||||
options.BaseURL = "https://api.minimax.io"
|
||||
}
|
||||
return &Provider{opts: options}
|
||||
}
|
||||
|
||||
func (p *Provider) Init(opts ...ai.Option) error {
|
||||
for _, o := range opts {
|
||||
o(&p.opts)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) Options() ai.Options { return p.opts }
|
||||
func (p *Provider) String() string { return "minimax" }
|
||||
|
||||
func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
|
||||
var tools []map[string]any
|
||||
for _, t := range req.Tools {
|
||||
tools = append(tools, map[string]any{
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": t.Name,
|
||||
"description": t.Description,
|
||||
"parameters": map[string]any{
|
||||
"type": "object",
|
||||
"properties": t.Properties,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
messages := []map[string]any{
|
||||
{"role": "system", "content": req.SystemPrompt},
|
||||
{"role": "user", "content": req.Prompt},
|
||||
}
|
||||
|
||||
apiReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"messages": messages,
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
apiReq["tools"] = tools
|
||||
}
|
||||
|
||||
resp, rawMessage, err := p.callAPI(ctx, apiReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
if p.opts.ToolHandler != nil {
|
||||
followUpMessages := append(messages, map[string]any{
|
||||
"role": "assistant",
|
||||
"content": rawMessage["content"],
|
||||
"tool_calls": rawMessage["tool_calls"],
|
||||
})
|
||||
for _, tc := range resp.ToolCalls {
|
||||
content := p.opts.ToolHandler(ctx, tc).Content
|
||||
followUpMessages = append(followUpMessages, map[string]any{
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.ID,
|
||||
"content": content,
|
||||
})
|
||||
}
|
||||
followUpResp, _, err := p.callAPI(ctx, map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"messages": followUpMessages,
|
||||
})
|
||||
if err == nil && followUpResp.Reply != "" {
|
||||
resp.Answer = followUpResp.Reply
|
||||
}
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
|
||||
return openaiapi.Stream(ctx, p.opts, req, "/v1/chat/completions")
|
||||
}
|
||||
|
||||
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
|
||||
reqBody, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/chat/completions"
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
|
||||
|
||||
httpResp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("API request failed: %w", err)
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
}
|
||||
|
||||
var chatResp struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
ToolCalls []struct {
|
||||
ID string `json:"id"`
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
} `json:"function"`
|
||||
} `json:"tool_calls"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(respBody, &chatResp); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
if len(chatResp.Choices) == 0 {
|
||||
return nil, nil, fmt.Errorf("no response from API")
|
||||
}
|
||||
|
||||
choice := chatResp.Choices[0]
|
||||
response := &ai.Response{Reply: choice.Message.Content}
|
||||
|
||||
for _, tc := range choice.Message.ToolCalls {
|
||||
var input map[string]any
|
||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil {
|
||||
input = map[string]any{}
|
||||
}
|
||||
response.ToolCalls = append(response.ToolCalls, ai.ToolCall{
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Input: input,
|
||||
})
|
||||
}
|
||||
|
||||
rawMessage := map[string]any{
|
||||
"content": choice.Message.Content,
|
||||
"tool_calls": choice.Message.ToolCalls,
|
||||
}
|
||||
|
||||
return response, rawMessage, nil
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package minimax
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
)
|
||||
|
||||
func TestProvider_String(t *testing.T) {
|
||||
if NewProvider().String() != "minimax" {
|
||||
t.Errorf("got %q", NewProvider().String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Defaults(t *testing.T) {
|
||||
opts := NewProvider().Options()
|
||||
if opts.Model != "MiniMax-M3" {
|
||||
t.Errorf("default model = %q", opts.Model)
|
||||
}
|
||||
if opts.BaseURL != "https://api.minimax.io" {
|
||||
t.Errorf("default base URL = %q", opts.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Init(t *testing.T) {
|
||||
p := NewProvider()
|
||||
if err := p.Init(ai.WithModel("m"), ai.WithAPIKey("k")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Options().Model != "m" || p.Options().APIKey != "k" {
|
||||
t.Error("Init did not apply options")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Generate_NoAPIKey(t *testing.T) {
|
||||
if _, err := NewProvider().Generate(context.Background(), &ai.Request{Prompt: "hi"}); err == nil {
|
||||
t.Error("expected error without API key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Stream(t *testing.T) {
|
||||
var sawStream bool
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/chat/completions" {
|
||||
t.Fatalf("path = %s, want /v1/chat/completions", r.URL.Path)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
sawStream, _ = body["stream"].(bool)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n"))
|
||||
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n"))
|
||||
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
|
||||
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hello"})
|
||||
if err != nil {
|
||||
t.Fatalf("Stream returned error: %v", err)
|
||||
}
|
||||
defer stream.Close()
|
||||
if !sawStream {
|
||||
t.Fatal("stream request did not set stream=true")
|
||||
}
|
||||
|
||||
first, err := stream.Recv()
|
||||
if err != nil || first.Reply != "hel" {
|
||||
t.Fatalf("first chunk = %#v, %v; want hel", first, err)
|
||||
}
|
||||
second, err := stream.Recv()
|
||||
if err != nil || second.Reply != "lo" {
|
||||
t.Fatalf("second chunk = %#v, %v; want lo", second, err)
|
||||
}
|
||||
if _, err := stream.Recv(); !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("final error = %v, want EOF", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Registration(t *testing.T) {
|
||||
m := ai.New("minimax", ai.WithAPIKey("test"))
|
||||
if m == nil {
|
||||
t.Fatal("provider not registered")
|
||||
}
|
||||
if m.String() != "minimax" {
|
||||
t.Errorf("got %q", m.String())
|
||||
}
|
||||
}
|
||||
@@ -212,6 +212,8 @@ func AutoDetectProvider(baseURL string) string {
|
||||
return "gemini"
|
||||
case strings.Contains(baseURL, "groq"):
|
||||
return "groq"
|
||||
case strings.Contains(baseURL, "minimax"):
|
||||
return "minimax"
|
||||
case strings.Contains(baseURL, "mistral"):
|
||||
return "mistral"
|
||||
case strings.Contains(baseURL, "together"):
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
_ "go-micro.dev/v6/ai/atlascloud"
|
||||
_ "go-micro.dev/v6/ai/gemini"
|
||||
_ "go-micro.dev/v6/ai/groq"
|
||||
_ "go-micro.dev/v6/ai/minimax"
|
||||
_ "go-micro.dev/v6/ai/mistral"
|
||||
_ "go-micro.dev/v6/ai/openai"
|
||||
_ "go-micro.dev/v6/ai/together"
|
||||
@@ -278,6 +279,7 @@ func conformingStreamProviders(t *testing.T) []string {
|
||||
allowed := map[string]struct{}{
|
||||
"atlascloud": {},
|
||||
"groq": {},
|
||||
"minimax": {},
|
||||
"mistral": {},
|
||||
"openai": {},
|
||||
"together": {},
|
||||
@@ -288,7 +290,7 @@ func conformingStreamProviders(t *testing.T) []string {
|
||||
out = append(out, provider)
|
||||
}
|
||||
}
|
||||
want := []string{"atlascloud", "groq", "mistral", "openai", "together"}
|
||||
want := []string{"atlascloud", "groq", "minimax", "mistral", "openai", "together"}
|
||||
if !reflect.DeepEqual(out, want) {
|
||||
t.Fatalf("conforming stream providers = %#v, want %#v (registered stream providers: %#v)", out, want, providers)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,30 @@ import (
|
||||
_ "go-micro.dev/v6/cmd/micro/cli/remote"
|
||||
)
|
||||
|
||||
const docsWayfinding = `First-agent and 0→hero docs:
|
||||
|
||||
1. No-secret first-agent transcript
|
||||
https://go-micro.dev/docs/guides/no-secret-first-agent.html
|
||||
Run the maintained support agent without a provider key:
|
||||
go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentTranscript -count=1
|
||||
|
||||
2. Your First Agent
|
||||
https://go-micro.dev/docs/guides/your-first-agent.html
|
||||
Build a service-backed agent, then use:
|
||||
micro agent preflight
|
||||
micro run
|
||||
micro chat
|
||||
|
||||
3. Debugging your agent
|
||||
https://go-micro.dev/docs/guides/debugging-agents.html
|
||||
Inspect agent runs and memory with:
|
||||
micro inspect agent
|
||||
micro runs <agent>
|
||||
|
||||
4. 0→hero Reference
|
||||
https://go-micro.dev/docs/guides/zero-to-hero.html
|
||||
Walk the scaffold → run → chat → inspect → deploy dry-run lifecycle.`
|
||||
|
||||
func genProtoHandler(c *cli.Context) error {
|
||||
cmd := exec.Command("find", ".", "-name", "*.proto", "-exec", "protoc", "--proto_path=.", "--micro_out=.", "--go_out=.", `{}`, `;`)
|
||||
cmd.Stdout = os.Stdout
|
||||
@@ -96,6 +120,17 @@ func init() {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "docs",
|
||||
Usage: "Show the first-agent and 0→hero documentation path",
|
||||
Description: `Print the maintained adoption on-ramp for new Go Micro developers:
|
||||
the no-secret first-agent transcript, Your First Agent, debugging guide, and
|
||||
0→hero lifecycle reference.`,
|
||||
Action: func(ctx *cli.Context) error {
|
||||
fmt.Fprintln(ctx.App.Writer, docsWayfinding)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "call",
|
||||
Usage: "Call a service",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -21,7 +22,7 @@ func TestFirstAgentWalkthroughCLIBoundaries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
for _, want := range []string{"new", "run", "chat", "inspect", "agent"} {
|
||||
for _, want := range []string{"new", "run", "chat", "inspect", "agent", "docs"} {
|
||||
if !commands[want] {
|
||||
t.Fatalf("first-agent walkthrough missing %q command", want)
|
||||
}
|
||||
@@ -37,6 +38,31 @@ func TestFirstAgentWalkthroughCLIBoundaries(t *testing.T) {
|
||||
if !strings.Contains(chat.Description, "services") || !strings.Contains(chat.Description, "agent") {
|
||||
t.Fatalf("micro chat should describe the service-to-agent walkthrough boundary; description was %q", chat.Description)
|
||||
}
|
||||
|
||||
docs := commandByName(t, "docs")
|
||||
if !strings.Contains(docs.Usage, "first-agent") || !strings.Contains(docs.Usage, "0→hero") {
|
||||
t.Fatalf("micro docs should advertise the first-agent and 0→hero docs path; usage was %q", docs.Usage)
|
||||
}
|
||||
var out bytes.Buffer
|
||||
app := cli.NewApp()
|
||||
app.Writer = &out
|
||||
if err := docs.Action(cli.NewContext(app, nil, nil)); err != nil {
|
||||
t.Fatalf("micro docs failed: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"no-secret-first-agent.html",
|
||||
"your-first-agent.html",
|
||||
"debugging-agents.html",
|
||||
"zero-to-hero.html",
|
||||
"micro agent preflight",
|
||||
"micro run",
|
||||
"micro chat",
|
||||
"micro inspect agent",
|
||||
} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("micro docs output missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func commandByName(t *testing.T, name string) *cli.Command {
|
||||
|
||||
@@ -166,22 +166,48 @@ func main() {
|
||||
|
||||
func readSSEData(r io.Reader) (string, error) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
var event strings.Builder
|
||||
var payload strings.Builder
|
||||
seen := false
|
||||
flush := func() error {
|
||||
data := strings.TrimSpace(event.String())
|
||||
event.Reset()
|
||||
if data == "" {
|
||||
return nil
|
||||
}
|
||||
if !json.Valid([]byte(data)) {
|
||||
return fmt.Errorf("SSE data event is not JSON: %s", data)
|
||||
}
|
||||
seen = true
|
||||
payload.WriteString(data)
|
||||
payload.WriteByte('\n')
|
||||
return nil
|
||||
}
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if data, ok := strings.CutPrefix(line, "data: "); ok {
|
||||
payload.WriteString(data)
|
||||
payload.WriteByte('\n')
|
||||
if strings.TrimSpace(line) == "" {
|
||||
if err := flush(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
continue
|
||||
}
|
||||
data, ok := strings.CutPrefix(line, "data:")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if event.Len() > 0 {
|
||||
event.WriteByte('\n')
|
||||
}
|
||||
event.WriteString(strings.TrimSpace(data))
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if payload.Len() == 0 {
|
||||
return "", errors.New("no SSE data received")
|
||||
if err := flush(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !json.Valid([]byte(strings.TrimSpace(payload.String()))) {
|
||||
return "", fmt.Errorf("SSE data is not JSON: %s", payload.String())
|
||||
if !seen {
|
||||
return "", errors.New("no SSE data received")
|
||||
}
|
||||
return payload.String(), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadSSEDataAcceptsMultipleJSONEvents(t *testing.T) {
|
||||
payload, err := readSSEData(strings.NewReader("event: status\ndata: {\"phase\":\"started\"}\n\ndata:{\"result\":\"a2a-fallback-ok\"}\n\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("readSSEData returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(payload, "started") || !strings.Contains(payload, "a2a-fallback-ok") {
|
||||
t.Fatalf("payload = %q, want both event payloads", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSSEDataRejectsInvalidJSONEvent(t *testing.T) {
|
||||
_, err := readSSEData(strings.NewReader("data: {\"ok\":true}\n\ndata: {bad json}\n\n"))
|
||||
if err == nil {
|
||||
t.Fatal("readSSEData returned nil error for invalid JSON event")
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,10 @@ func (s *TaskService) count() int {
|
||||
return len(s.tasks)
|
||||
}
|
||||
|
||||
const delegatedNotifyTask = "Use the notify Send tool exactly once to tell owner@acme.com: The launch plan is ready. Do not answer until the notify tool call has succeeded."
|
||||
|
||||
const commsPrompt = "You handle outbound notifications. When asked to notify someone, you must call the notify Send tool exactly once before replying. Never claim a notification was sent unless the notify tool returned success."
|
||||
|
||||
type SendRequest struct {
|
||||
To string `json:"to" description:"Recipient address"`
|
||||
Message string `json:"message" description:"Message body"`
|
||||
@@ -275,12 +279,12 @@ func (m *mockModel) Generate(ctx context.Context, req *ai.Request, _ ...ai.Gener
|
||||
if m.unknownDelegateOnce && !m.emittedUnknownDelegate {
|
||||
m.emittedUnknownDelegate = true
|
||||
m.call("conductor", "atlascloud_delegate", map[string]any{
|
||||
"task": "Notify owner@acme.com that the launch plan is ready",
|
||||
"task": delegatedNotifyTask,
|
||||
"to": "comms",
|
||||
})
|
||||
} else {
|
||||
m.call("conductor", del, map[string]any{
|
||||
"task": "Notify owner@acme.com that the launch plan is ready",
|
||||
"task": delegatedNotifyTask,
|
||||
"to": "comms",
|
||||
})
|
||||
}
|
||||
@@ -354,7 +358,7 @@ func runPlanDelegate(provider string) error {
|
||||
agent.Name("comms"),
|
||||
agent.Address("127.0.0.1:0"),
|
||||
agent.Services("notify"),
|
||||
agent.Prompt("You handle outbound notifications. Use the notify service."),
|
||||
agent.Prompt(commsPrompt),
|
||||
agent.Provider(provider), agent.APIKey(apiKey),
|
||||
agent.WithRegistry(reg), agent.WithClient(cl), agent.WithStore(mem),
|
||||
agent.WithCheckpoint(commsCheckpoint),
|
||||
@@ -413,7 +417,10 @@ func runPlanDelegate(provider string) error {
|
||||
executeDone <- f.Execute(ctx, "launch readiness")
|
||||
}()
|
||||
|
||||
if err := waitForPlanDelegateExecution(executeDone, notifySvc); err != nil {
|
||||
if err := waitForPlanDelegateExecution(executeDone, taskSvc, notifySvc, func(ctx context.Context) error {
|
||||
_, err := conductor.Ask(ctx, "The Design, Build, and Ship tasks already exist, but the owner notification is still missing. Delegate exactly one notification to the \"comms\" agent now with this exact subtask: "+delegatedNotifyTask+" Do not create more tasks and do not answer until comms has handled the notification.")
|
||||
return err
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -435,14 +442,35 @@ func runPlanDelegate(provider string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func waitForPlanDelegateExecution(done <-chan error, notifySvc *NotifyService) error {
|
||||
func waitForPlanDelegateExecution(done <-chan error, taskSvc *TaskService, notifySvc *NotifyService, recoverMissingNotify func(context.Context) error) error {
|
||||
ticker := time.NewTicker(50 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case err := <-done:
|
||||
tasks := taskSvc.count()
|
||||
notify := notifySvc.count()
|
||||
if err != nil {
|
||||
return fmt.Errorf("flow execute: %w", err)
|
||||
if isClientTimeout(err) && tasks == 3 && notify == 1 {
|
||||
fmt.Printf("\n\033[33mwarning:\033[0m flow execute returned after completed side effects: %v\n", err)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("flow execute after side effects tasks=%d notify=%d: %w", tasks, notify, err)
|
||||
}
|
||||
if notify != 1 {
|
||||
if recoverMissingNotify == nil || tasks != 3 || notify != 0 {
|
||||
return fmt.Errorf("delegation completed without required notify side effect: notify=%d, want 1", notify)
|
||||
}
|
||||
fmt.Print("\n\033[33mwarning:\033[0m flow completed before delegated notify; retrying the missing comms handoff once.\n")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
retryErr := recoverMissingNotify(ctx)
|
||||
cancel()
|
||||
if retryErr != nil {
|
||||
return fmt.Errorf("delegation completed without required notify side effect and recovery failed: notify=%d, want 1: %w", notify, retryErr)
|
||||
}
|
||||
if notify = notifySvc.count(); notify != 1 {
|
||||
return fmt.Errorf("delegation recovery completed without required notify side effect: notify=%d, want 1", notify)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
@@ -453,6 +481,11 @@ func waitForPlanDelegateExecution(done <-chan error, notifySvc *NotifyService) e
|
||||
}
|
||||
}
|
||||
|
||||
func isClientTimeout(err error) bool {
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "request timeout") || strings.Contains(msg, "code=408") || strings.Contains(msg, "code\":408")
|
||||
}
|
||||
|
||||
func main() {
|
||||
provider := flag.String("provider", "mock", "LLM provider: mock (default), mock-unknown-delegate, anthropic, openai, gemini, groq, mistral, together, atlascloud")
|
||||
flag.Parse()
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -66,7 +67,7 @@ func TestPlanDelegateEndToEnd(t *testing.T) {
|
||||
agent.Name("comms"),
|
||||
agent.Address("127.0.0.1:0"),
|
||||
agent.Services("notify"),
|
||||
agent.Prompt("You handle outbound notifications."),
|
||||
agent.Prompt(commsPrompt),
|
||||
agent.Provider("mock"),
|
||||
agent.WithRegistry(reg),
|
||||
agent.WithClient(cl),
|
||||
@@ -148,7 +149,7 @@ func TestFlowDispatchesToAgentEndToEnd(t *testing.T) {
|
||||
agent.Name("comms"),
|
||||
agent.Address("127.0.0.1:0"),
|
||||
agent.Services("notify"),
|
||||
agent.Prompt("You handle outbound notifications."),
|
||||
agent.Prompt(commsPrompt),
|
||||
agent.Provider("mock"),
|
||||
agent.WithRegistry(reg),
|
||||
agent.WithClient(cl),
|
||||
@@ -257,7 +258,7 @@ func TestPlanDelegateExecutionReportsDuplicateNotifyBeforeTimeout(t *testing.T)
|
||||
|
||||
done := make(chan error)
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- waitForPlanDelegateExecution(done, notifySvc) }()
|
||||
go func() { errCh <- waitForPlanDelegateExecution(done, new(TaskService), notifySvc, nil) }()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
@@ -272,6 +273,84 @@ func TestPlanDelegateExecutionReportsDuplicateNotifyBeforeTimeout(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanDelegateExecutionRejectsClaimedCompletionWithoutNotify(t *testing.T) {
|
||||
notifySvc := new(NotifyService)
|
||||
done := make(chan error, 1)
|
||||
done <- nil
|
||||
|
||||
err := waitForPlanDelegateExecution(done, new(TaskService), notifySvc, nil)
|
||||
if err == nil {
|
||||
t.Fatal("waitForPlanDelegateExecution returned nil, want missing notify side-effect error")
|
||||
}
|
||||
if got := err.Error(); !strings.Contains(got, "without required notify side effect") {
|
||||
t.Fatalf("error = %q, want missing notify side-effect error", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanDelegateExecutionRecoversMissingNotifyOnce(t *testing.T) {
|
||||
taskSvc := new(TaskService)
|
||||
for _, title := range []string{"Design", "Build", "Ship"} {
|
||||
var rsp AddResponse
|
||||
if err := taskSvc.Add(context.Background(), &AddRequest{Title: title}, &rsp); err != nil {
|
||||
t.Fatalf("Add(%q): %v", title, err)
|
||||
}
|
||||
}
|
||||
notifySvc := new(NotifyService)
|
||||
done := make(chan error, 1)
|
||||
done <- nil
|
||||
|
||||
recovered := false
|
||||
err := waitForPlanDelegateExecution(done, taskSvc, notifySvc, func(ctx context.Context) error {
|
||||
recovered = true
|
||||
var rsp SendResponse
|
||||
return notifySvc.Send(ctx, &SendRequest{To: "owner@acme.com", Message: "The launch plan is ready"}, &rsp)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("waitForPlanDelegateExecution returned %v, want recovery success", err)
|
||||
}
|
||||
if !recovered {
|
||||
t.Fatal("missing notify recovery was not invoked")
|
||||
}
|
||||
if got := notifySvc.count(); got != 1 {
|
||||
t.Fatalf("notify count = %d, want 1 after recovery", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanDelegateExecutionAcceptsClientTimeoutAfterSideEffects(t *testing.T) {
|
||||
taskSvc := new(TaskService)
|
||||
for _, title := range []string{"Design", "Build", "Ship"} {
|
||||
var rsp AddResponse
|
||||
if err := taskSvc.Add(context.Background(), &AddRequest{Title: title}, &rsp); err != nil {
|
||||
t.Fatalf("Add(%q): %v", title, err)
|
||||
}
|
||||
}
|
||||
notifySvc := new(NotifyService)
|
||||
var rsp SendResponse
|
||||
if err := notifySvc.Send(context.Background(), &SendRequest{To: "owner@acme.com", Message: "The launch plan is ready"}, &rsp); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
done <- errors.New(`{"id":"go.micro.client","code":408,"detail":"<nil>","status":"Request Timeout"}`)
|
||||
|
||||
if err := waitForPlanDelegateExecution(done, taskSvc, notifySvc, nil); err != nil {
|
||||
t.Fatalf("waitForPlanDelegateExecution returned %v, want completed side effects to satisfy client timeout", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanDelegateExecutionRejectsClientTimeoutBeforeSideEffects(t *testing.T) {
|
||||
done := make(chan error, 1)
|
||||
done <- errors.New(`{"id":"go.micro.client","code":408,"detail":"<nil>","status":"Request Timeout"}`)
|
||||
|
||||
err := waitForPlanDelegateExecution(done, new(TaskService), new(NotifyService), nil)
|
||||
if err == nil {
|
||||
t.Fatal("waitForPlanDelegateExecution returned nil, want timeout before side effects to fail")
|
||||
}
|
||||
if got := err.Error(); !strings.Contains(got, "tasks=0 notify=0") {
|
||||
t.Fatalf("error = %q, want side-effect counts", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyServiceSendIsIdempotentForDuplicateDelivery(t *testing.T) {
|
||||
svc := new(NotifyService)
|
||||
for i := 0; i < 3; i++ {
|
||||
|
||||
@@ -51,6 +51,7 @@ Provider keys are read from `MICRO_AI_API_KEY` or the provider-specific variable
|
||||
| OpenAI | `OPENAI_API_KEY` |
|
||||
| Gemini | `GEMINI_API_KEY` |
|
||||
| Groq | `GROQ_API_KEY` |
|
||||
| MiniMax | `MINIMAX_API_KEY` |
|
||||
| Mistral | `MISTRAL_API_KEY` |
|
||||
| Together | `TOGETHER_API_KEY` |
|
||||
| AtlasCloud | `ATLASCLOUD_API_KEY` |
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
_ "go-micro.dev/v6/ai/atlascloud"
|
||||
_ "go-micro.dev/v6/ai/gemini"
|
||||
_ "go-micro.dev/v6/ai/groq"
|
||||
_ "go-micro.dev/v6/ai/minimax"
|
||||
_ "go-micro.dev/v6/ai/mistral"
|
||||
_ "go-micro.dev/v6/ai/openai"
|
||||
_ "go-micro.dev/v6/ai/together"
|
||||
@@ -49,6 +50,7 @@ var providerEnv = map[string]string{
|
||||
"openai": "OPENAI_API_KEY",
|
||||
"gemini": "GEMINI_API_KEY",
|
||||
"groq": "GROQ_API_KEY",
|
||||
"minimax": "MINIMAX_API_KEY",
|
||||
"mistral": "MISTRAL_API_KEY",
|
||||
"together": "TOGETHER_API_KEY",
|
||||
"atlascloud": "ATLASCLOUD_API_KEY",
|
||||
|
||||
@@ -38,7 +38,7 @@ func TestValidateSelectionRejectsUnsafeHarnessName(t *testing.T) {
|
||||
|
||||
func TestDefaultProvidersTracksLiveProviderSet(t *testing.T) {
|
||||
got := defaultProviders()
|
||||
for _, want := range []string{"anthropic", "openai", "gemini", "groq", "mistral", "together", "atlascloud"} {
|
||||
for _, want := range []string{"anthropic", "openai", "gemini", "groq", "minimax", "mistral", "together", "atlascloud"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("defaultProviders() = %q, want %q", got, want)
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func TestCapabilityMatrixHasRegisteredProviders(t *testing.T) {
|
||||
t.Fatal("CapabilityRows returned no providers")
|
||||
}
|
||||
|
||||
var foundOpenAI bool
|
||||
var foundOpenAI, foundMiniMax bool
|
||||
for _, row := range rows {
|
||||
if row.Provider == "openai" {
|
||||
foundOpenAI = true
|
||||
@@ -62,10 +62,19 @@ func TestCapabilityMatrixHasRegisteredProviders(t *testing.T) {
|
||||
t.Fatalf("openai capabilities = %#v, want model+image only", row.Capabilities)
|
||||
}
|
||||
}
|
||||
if row.Provider == "minimax" {
|
||||
foundMiniMax = true
|
||||
if !row.Model || !row.Stream || row.Image || row.Video {
|
||||
t.Fatalf("minimax capabilities = %#v, want model+stream only", row.Capabilities)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundOpenAI {
|
||||
t.Fatalf("CapabilityRows = %#v, want openai row", rows)
|
||||
}
|
||||
if !foundMiniMax {
|
||||
t.Fatalf("CapabilityRows = %#v, want minimax row", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCapabilityMarkdown(t *testing.T) {
|
||||
|
||||
@@ -27,6 +27,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
@@ -39,6 +40,8 @@ import (
|
||||
"go-micro.dev/v6/agent"
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/broker"
|
||||
"go-micro.dev/v6/client"
|
||||
codecbytes "go-micro.dev/v6/codec/bytes"
|
||||
"go-micro.dev/v6/flow"
|
||||
"go-micro.dev/v6/gateway/a2a"
|
||||
"go-micro.dev/v6/internal/harness/harnessutil"
|
||||
@@ -109,9 +112,10 @@ type SendResponse struct {
|
||||
}
|
||||
|
||||
type Notify struct {
|
||||
mu sync.Mutex
|
||||
sent int64
|
||||
seen map[string]struct{}
|
||||
mu sync.Mutex
|
||||
sent int64
|
||||
seen map[string]struct{}
|
||||
lastRejected *SendRequest
|
||||
}
|
||||
|
||||
// Send delivers a notification.
|
||||
@@ -122,11 +126,14 @@ func (s *Notify) Send(_ context.Context, req *SendRequest, rsp *SendResponse) er
|
||||
if req != nil {
|
||||
to, message = req.To, req.Message
|
||||
}
|
||||
s.recordRejected(to, message)
|
||||
fmt.Printf(" \033[35m[notify]\033[0m 📨 ignored non-buyer notification to=%s %q\n", to, message)
|
||||
rsp.Sent = false
|
||||
return nil
|
||||
}
|
||||
|
||||
s.recordRejected("", "")
|
||||
|
||||
keys := notificationDedupeKeys(req)
|
||||
s.mu.Lock()
|
||||
if s.seen == nil {
|
||||
@@ -158,12 +165,34 @@ func isBuyerNotification(req *SendRequest) bool {
|
||||
return canonicalBuyerRecipient(req.To) != ""
|
||||
}
|
||||
|
||||
func (s *Notify) recordRejected(to, message string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if strings.TrimSpace(to) == "" && strings.TrimSpace(message) == "" {
|
||||
s.lastRejected = nil
|
||||
return
|
||||
}
|
||||
s.lastRejected = &SendRequest{To: to, Message: message}
|
||||
}
|
||||
|
||||
func (s *Notify) rejectedSummary() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.lastRejected == nil {
|
||||
return "no rejected notify call observed"
|
||||
}
|
||||
return fmt.Sprintf("last notify args to=%q message=%q", s.lastRejected.To, s.lastRejected.Message)
|
||||
}
|
||||
|
||||
func canonicalBuyerRecipient(to string) string {
|
||||
recipient := strings.ToLower(strings.TrimSpace(to))
|
||||
switch recipient {
|
||||
case "buyer", "buyer@acme.com":
|
||||
return "buyer@acme.com"
|
||||
}
|
||||
if strings.HasPrefix(recipient, "buyer-of-order-") && len(recipient) > len("buyer-of-order-") {
|
||||
return "buyer@acme.com"
|
||||
}
|
||||
for _, field := range strings.FieldsFunc(recipient, func(r rune) bool {
|
||||
switch r {
|
||||
case ' ', '\t', '\n', '\r', ',', ';', ':', '/', '\\', '(', ')', '[', ']', '{', '}':
|
||||
@@ -199,18 +228,40 @@ func notificationDedupeKeys(req *SendRequest) []string {
|
||||
return keys
|
||||
}
|
||||
|
||||
func dispatchNotifyStep(agentName string, ntf *Notify) flow.StepFunc {
|
||||
dispatch := flow.Dispatch(agentName)
|
||||
func dispatchNotifyStep(agentName string, cl client.Client, ntf *Notify) flow.StepFunc {
|
||||
return func(ctx context.Context, in flow.State) (flow.State, error) {
|
||||
before := atomic.LoadInt64(&ntf.sent)
|
||||
out, err := dispatch(ctx, in)
|
||||
if err == nil {
|
||||
return out, nil
|
||||
out, err := dispatchBuyerNotification(ctx, agentName, cl, in)
|
||||
if err != nil {
|
||||
out = in
|
||||
}
|
||||
return completeNotifyOnObservedSideEffect(ctx, in, ntf, before, 2*time.Second, err)
|
||||
return completeNotifyOnObservedSideEffect(ctx, out, ntf, before, 2*time.Second, err)
|
||||
}
|
||||
}
|
||||
|
||||
func dispatchBuyerNotification(ctx context.Context, agentName string, cl client.Client, in flow.State) (flow.State, error) {
|
||||
if cl == nil {
|
||||
cl = client.DefaultClient
|
||||
}
|
||||
info, _ := ai.RunInfoFrom(ctx)
|
||||
message := fmt.Sprintf(
|
||||
"Checkout flow confirmed this order: %s. Use notify.Send exactly once to notify buyer@acme.com that the order is confirmed. Do not reply until the notify tool call has completed.",
|
||||
strings.TrimSpace(in.String()),
|
||||
)
|
||||
body, _ := json.Marshal(map[string]string{"message": message, "parent_id": info.RunID})
|
||||
req := cl.NewRequest(agentName, "Agent.Chat", &codecbytes.Frame{Data: body})
|
||||
var rsp codecbytes.Frame
|
||||
if err := cl.Call(ctx, req, &rsp); err != nil {
|
||||
return in, err
|
||||
}
|
||||
var out struct {
|
||||
Reply string `json:"reply"`
|
||||
}
|
||||
_ = json.Unmarshal(rsp.Data, &out)
|
||||
in.Data = []byte(out.Reply)
|
||||
return in, nil
|
||||
}
|
||||
|
||||
func completeNotifyOnObservedSideEffect(ctx context.Context, in flow.State, ntf *Notify, before int64, wait time.Duration, dispatchErr error) (flow.State, error) {
|
||||
deadline := time.Now().Add(wait)
|
||||
for time.Now().Before(deadline) {
|
||||
@@ -220,11 +271,17 @@ func completeNotifyOnObservedSideEffect(ctx context.Context, in flow.State, ntf
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return in, dispatchErr
|
||||
if dispatchErr != nil {
|
||||
return in, dispatchErr
|
||||
}
|
||||
return in, ctx.Err()
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
return in, dispatchErr
|
||||
if dispatchErr != nil {
|
||||
return in, dispatchErr
|
||||
}
|
||||
return in, fmt.Errorf("concierge completed without notifying buyer: notify count stayed at %d; expected recipient buyer@acme.com, buyer, or buyer-of-order-<id>; %s", before, ntf.rejectedSummary())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -397,7 +454,7 @@ func runUniverse(provider string) int {
|
||||
flow.Step{Name: "reserve", Run: flow.Call("inventory", "Inventory.Reserve")},
|
||||
flow.Step{Name: "charge", Run: flow.Call("payment", "Payment.Charge")},
|
||||
flow.Step{Name: "confirm", Run: flow.Call("orders", "Orders.Confirm")},
|
||||
flow.Step{Name: "notify", Run: dispatchNotifyStep("concierge", ntf)},
|
||||
flow.Step{Name: "notify", Run: dispatchNotifyStep("concierge", cl, ntf)},
|
||||
),
|
||||
)
|
||||
if err := checkout.Register(reg, br, cl); err != nil {
|
||||
|
||||
@@ -69,6 +69,27 @@ func TestNotifyStepCompletesAfterObservedSideEffectTimeout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyStepRejectsClaimedCompletionWithoutSideEffect(t *testing.T) {
|
||||
ntf := new(Notify)
|
||||
before := atomic.LoadInt64(&ntf.sent)
|
||||
|
||||
_, err := completeNotifyOnObservedSideEffect(
|
||||
context.Background(),
|
||||
flow.State{Data: []byte(`claimed success`)},
|
||||
ntf,
|
||||
before,
|
||||
25*time.Millisecond,
|
||||
nil,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("notify completion returned nil, want missing buyer notification error")
|
||||
}
|
||||
want := `concierge completed without notifying buyer: notify count stayed at 0; expected recipient buyer@acme.com, buyer, or buyer-of-order-<id>; no rejected notify call observed`
|
||||
if got := err.Error(); got != want {
|
||||
t.Fatalf("error = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifySuppressesEquivalentConfirmationMessages(t *testing.T) {
|
||||
ntf := new(Notify)
|
||||
ctx := context.Background()
|
||||
@@ -161,7 +182,7 @@ func TestNotifyAcceptsOrderScopedBuyerRecipient(t *testing.T) {
|
||||
|
||||
var rsp SendResponse
|
||||
if err := ntf.Send(ctx, &SendRequest{
|
||||
To: "order-1 buyer",
|
||||
To: "buyer-of-order-1",
|
||||
Message: "order-1 confirmed",
|
||||
}, &rsp); err != nil {
|
||||
t.Fatalf("send order-scoped buyer notification: %v", err)
|
||||
@@ -186,3 +207,30 @@ func TestNotifyAcceptsOrderScopedBuyerRecipient(t *testing.T) {
|
||||
t.Fatalf("notifications sent after hyphenated non-buyer = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyStepReportsRejectedRecipientDiagnostics(t *testing.T) {
|
||||
ntf := new(Notify)
|
||||
var rsp SendResponse
|
||||
if err := ntf.Send(context.Background(), &SendRequest{
|
||||
To: "order-1",
|
||||
Message: "order-1 confirmed",
|
||||
}, &rsp); err != nil {
|
||||
t.Fatalf("send rejected notification: %v", err)
|
||||
}
|
||||
|
||||
_, err := completeNotifyOnObservedSideEffect(
|
||||
context.Background(),
|
||||
flow.State{Data: []byte(`claimed success`)},
|
||||
ntf,
|
||||
0,
|
||||
25*time.Millisecond,
|
||||
nil,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("notify completion returned nil, want diagnostics")
|
||||
}
|
||||
want := `concierge completed without notifying buyer: notify count stayed at 0; expected recipient buyer@acme.com, buyer, or buyer-of-order-<id>; last notify args to="order-1" message="order-1 confirmed"`
|
||||
if got := err.Error(); got != want {
|
||||
t.Fatalf("error = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ The built-in providers currently register these capability interfaces:
|
||||
| `atlascloud` | Yes | Yes | Yes | Yes |
|
||||
| `gemini` | Yes | No | No | No |
|
||||
| `groq` | Yes | No | No | Yes |
|
||||
| `minimax` | Yes | No | No | Yes |
|
||||
| `mistral` | Yes | No | No | Yes |
|
||||
| `ollama` | Yes | No | No | Yes |
|
||||
| `openai` | Yes | Yes | No | Yes |
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
_ "go-micro.dev/v6/ai/atlascloud"
|
||||
_ "go-micro.dev/v6/ai/gemini"
|
||||
_ "go-micro.dev/v6/ai/groq"
|
||||
_ "go-micro.dev/v6/ai/minimax"
|
||||
_ "go-micro.dev/v6/ai/mistral"
|
||||
_ "go-micro.dev/v6/ai/ollama"
|
||||
_ "go-micro.dev/v6/ai/openai"
|
||||
|
||||
+39
-54
@@ -3,7 +3,6 @@ package store
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -12,15 +11,20 @@ import (
|
||||
"github.com/kr/pretty"
|
||||
)
|
||||
|
||||
func cleanup(db string, s Store) {
|
||||
s.Close()
|
||||
dir := filepath.Join(DefaultDir, db+"/")
|
||||
os.RemoveAll(dir)
|
||||
func newTestFileStore(t *testing.T, opts ...Option) Store {
|
||||
t.Helper()
|
||||
opts = append(opts, DirOption(t.TempDir()))
|
||||
s := NewStore(opts...)
|
||||
t.Cleanup(func() {
|
||||
if err := s.Close(); err != nil {
|
||||
t.Errorf("failed to close file store: %v", err)
|
||||
}
|
||||
})
|
||||
return s
|
||||
}
|
||||
|
||||
func TestFileStoreReInit(t *testing.T) {
|
||||
s := NewStore(Table("aaa"))
|
||||
defer cleanup(DefaultDatabase, s)
|
||||
s := newTestFileStore(t, Table("aaa"))
|
||||
s.Init(Table("bbb"))
|
||||
if s.Options().Table != "bbb" {
|
||||
t.Error("Init didn't reinitialise the store")
|
||||
@@ -28,26 +32,22 @@ func TestFileStoreReInit(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFileStoreBasic(t *testing.T) {
|
||||
s := NewStore()
|
||||
defer cleanup(DefaultDatabase, s)
|
||||
s := newTestFileStore(t)
|
||||
fileTest(s, t)
|
||||
}
|
||||
|
||||
func TestFileStoreTable(t *testing.T) {
|
||||
s := NewStore(Table("testTable"))
|
||||
defer cleanup(DefaultDatabase, s)
|
||||
s := newTestFileStore(t, Table("testTable"))
|
||||
fileTest(s, t)
|
||||
}
|
||||
|
||||
func TestFileStoreDatabase(t *testing.T) {
|
||||
s := NewStore(Database("testdb"))
|
||||
defer cleanup("testdb", s)
|
||||
s := newTestFileStore(t, Database("testdb"))
|
||||
fileTest(s, t)
|
||||
}
|
||||
|
||||
func TestFileStoreDatabaseTable(t *testing.T) {
|
||||
s := NewStore(Table("testTable"), Database("testdb"))
|
||||
defer cleanup("testdb", s)
|
||||
s := newTestFileStore(t, Table("testTable"), Database("testdb"))
|
||||
fileTest(s, t)
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ func fileTest(s Store, t *testing.T) {
|
||||
{
|
||||
Key: "foobar",
|
||||
Value: []byte("foobarfoobar"),
|
||||
Expiry: time.Millisecond * 100,
|
||||
Expiry: time.Second, // wide window: CI I/O under -race can exceed a 100ms expiry before the read below
|
||||
},
|
||||
}
|
||||
|
||||
@@ -113,8 +113,8 @@ func fileTest(s Store, t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// wait for the expiry
|
||||
time.Sleep(time.Millisecond * 200)
|
||||
// wait for the expiry (must exceed the 1s Expiry above, with margin for slow CI)
|
||||
time.Sleep(time.Second * 2)
|
||||
|
||||
if results, err := s.Read("foo", ReadPrefix()); err != nil {
|
||||
t.Errorf("Couldn't read all \"foo\" keys, got %# v (%s)", spew.Sdump(results), err)
|
||||
@@ -135,22 +135,22 @@ func fileTest(s Store, t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Write 3 records with various expiry and get with Suffix
|
||||
// Write records with suffix matches and an already-expired record. Avoid
|
||||
// wall-clock boundary sleeps here: under -race/-cover, sleeping exactly the
|
||||
// TTL made this assertion flaky on slower CI runners.
|
||||
records = []*Record{
|
||||
{
|
||||
Key: "foo",
|
||||
Value: []byte("foofoo"),
|
||||
},
|
||||
{
|
||||
Key: "barfoo",
|
||||
Value: []byte("barfoobarfoo"),
|
||||
|
||||
Expiry: time.Millisecond * 100,
|
||||
Key: "barfoo",
|
||||
Value: []byte("barfoobarfoo"),
|
||||
Expiry: -time.Second,
|
||||
},
|
||||
{
|
||||
Key: "bazbarfoo",
|
||||
Value: []byte("bazbarfoobazbarfoo"),
|
||||
Expiry: 2 * time.Millisecond * 100,
|
||||
Key: "bazbarfoo",
|
||||
Value: []byte("bazbarfoobazbarfoo"),
|
||||
},
|
||||
}
|
||||
for _, r := range records {
|
||||
@@ -160,59 +160,44 @@ func fileTest(s Store, t *testing.T) {
|
||||
}
|
||||
if results, err := s.Read("foo", ReadSuffix()); err != nil {
|
||||
t.Errorf("Couldn't read all \"foo\" keys, got %# v (%s)", spew.Sdump(results), err)
|
||||
} else {
|
||||
if len(results) != 3 {
|
||||
t.Errorf("Expected 3 items, got %d", len(results))
|
||||
// t.Logf("Table test: %v\n", spew.Sdump(results))
|
||||
}
|
||||
} else if len(results) != 2 {
|
||||
t.Errorf("Expected 2 unexpired suffix items, got %d (%# v)", len(results), spew.Sdump(results))
|
||||
}
|
||||
if err := s.Delete("bazbarfoo"); err != nil {
|
||||
t.Errorf("Delete failed (%v)", err)
|
||||
}
|
||||
time.Sleep(time.Millisecond * 100)
|
||||
if results, err := s.Read("foo", ReadSuffix()); err != nil {
|
||||
t.Errorf("Couldn't read all \"foo\" keys, got %# v (%s)", spew.Sdump(results), err)
|
||||
} else {
|
||||
if len(results) != 2 {
|
||||
t.Errorf("Expected 2 items, got %d", len(results))
|
||||
// t.Logf("Table test: %v\n", spew.Sdump(results))
|
||||
}
|
||||
}
|
||||
time.Sleep(time.Millisecond * 100)
|
||||
if results, err := s.Read("foo", ReadSuffix()); err != nil {
|
||||
t.Errorf("Couldn't read all \"foo\" keys, got %# v (%s)", spew.Sdump(results), err)
|
||||
} else {
|
||||
if len(results) != 1 {
|
||||
t.Errorf("Expected 1 item, got %d", len(results))
|
||||
// t.Logf("Table test: %# v\n", spew.Sdump(results))
|
||||
}
|
||||
} else if len(results) != 1 {
|
||||
t.Errorf("Expected 1 unexpired suffix item, got %d (%# v)", len(results), spew.Sdump(results))
|
||||
}
|
||||
if err := s.Delete("foo"); err != nil {
|
||||
t.Errorf("Delete failed (%v)", err)
|
||||
}
|
||||
if results, err := s.Read("foo", ReadSuffix()); err != nil {
|
||||
t.Errorf("Couldn't read all \"foo\" keys, got %# v (%s)", spew.Sdump(results), err)
|
||||
} else {
|
||||
if len(results) != 0 {
|
||||
t.Errorf("Expected 0 items, got %d (%# v)", len(results), spew.Sdump(results))
|
||||
}
|
||||
} else if len(results) != 0 {
|
||||
t.Errorf("Expected 0 items, got %d (%# v)", len(results), spew.Sdump(results))
|
||||
}
|
||||
|
||||
// Test Table, Suffix and WriteOptions
|
||||
if err := s.Write(&Record{
|
||||
Key: "foofoobarbar",
|
||||
Value: []byte("something"),
|
||||
}, WriteTTL(time.Millisecond*100)); err != nil {
|
||||
}, WriteTTL(time.Second)); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := s.Write(&Record{
|
||||
Key: "foofoo",
|
||||
Value: []byte("something"),
|
||||
}, WriteExpiry(time.Now().Add(time.Millisecond*100))); err != nil {
|
||||
}, WriteExpiry(time.Now().Add(time.Second))); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := s.Write(&Record{
|
||||
Key: "barbar",
|
||||
Value: []byte("something"),
|
||||
// TTL has higher precedence than expiry
|
||||
}, WriteExpiry(time.Now().Add(time.Hour)), WriteTTL(time.Millisecond*100)); err != nil {
|
||||
}, WriteExpiry(time.Now().Add(time.Hour)), WriteTTL(time.Second)); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
@@ -224,7 +209,7 @@ func fileTest(s Store, t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(time.Millisecond * 100)
|
||||
time.Sleep(time.Second * 2) // exceed the 1s TTL/expiry above so everything has expired
|
||||
|
||||
if results, err := s.List(); err != nil {
|
||||
t.Errorf("List failed: %s", err)
|
||||
|
||||
Reference in New Issue
Block a user