Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 749a3ee382 | |||
| 5dc2a32233 | |||
| d8562edcf6 | |||
| 5a85cba982 |
@@ -21,7 +21,9 @@ changes, architectural rewrites. Those go to the human.
|
||||
|
||||
## Work queue (ranked)
|
||||
|
||||
1. **Add examples wayfinding index for first-agent adoption** ([#4223](https://github.com/micro/go-micro/issues/4223)) — keep developer adoption weighted with internal hardening: the README and getting-started guide now have a stronger first-agent path, but examples remain spread across docs, CLI output, and directories. A single CI-guarded examples map should make the smallest no-secret agent, the 0→hero support app, and next interop examples discoverable from one place.
|
||||
1. **Prevent duplicate delegated notifications in plan-delegate harness** ([#4245](https://github.com/micro/go-micro/issues/4245)) — Now-phase safety is the current red edge after the AtlasCloud 400 fallback shipped: the services → agents → workflows loop must not repeat delegated side effects while recovering or continuing a plan. Make the plan-delegate path idempotent and CI-verifiable before adding more depth.
|
||||
2. **Make AtlasCloud guarded delegation pass reliably** ([#4244](https://github.com/micro/go-micro/issues/4244)) — keep cross-provider conformance close behind the side-effect fix: AtlasCloud now gets past the Minimax request-shape 400s, but the live agent harness still misses the required guarded delegate within the retry budget.
|
||||
3. **Link examples wayfinding from website getting-started path** ([#4241](https://github.com/micro/go-micro/issues/4241)) — keep adoption weighted with hardening after the examples index shipped: the repo README and CLI now point at the first-agent/0→hero map, but go-micro.dev getting-started and quickstart pages should expose the same path with a CI-guarded docs smoke check.
|
||||
|
||||
_Seeded by Claude Code from the roadmap + open issues; thereafter maintained by the
|
||||
architecture-review pass._
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -94,6 +95,7 @@ func (p *Provider) String() string { return "atlascloud" }
|
||||
|
||||
func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
|
||||
tools := atlascloudTools(req.Tools)
|
||||
compatTools, compatPrompt := atlascloudMinimaxCompatTools(p.opts.Model, req.Tools)
|
||||
|
||||
messages := []map[string]any{
|
||||
{"role": "system", "content": req.SystemPrompt},
|
||||
@@ -104,6 +106,9 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
if req.Prompt != "" {
|
||||
messages = append(messages, map[string]any{"role": "user", "content": req.Prompt})
|
||||
}
|
||||
if compatPrompt != "" {
|
||||
messages = append(messages, map[string]any{"role": "system", "content": compatPrompt})
|
||||
}
|
||||
|
||||
apiReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
@@ -119,7 +124,13 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
|
||||
resp, rawMessage, err := p.callAPI(ctx, "chat", apiReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if atlascloudShouldRetryMinimaxCompat(err, compatTools) {
|
||||
apiReq["tools"] = compatTools
|
||||
resp, rawMessage, err = p.callAPI(ctx, "chat-minimax-compat", apiReq)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
@@ -161,7 +172,13 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
|
||||
followUpResp, _, err := p.callAPI(ctx, "tool-follow-up", followUpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if atlascloudShouldRetryWithoutTools(err, followUpReq) {
|
||||
delete(followUpReq, "tools")
|
||||
followUpResp, _, err = p.callAPI(ctx, "tool-follow-up-no-tools", followUpReq)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(followUpResp.ToolCalls) > 0 {
|
||||
for i := range followUpResp.ToolCalls {
|
||||
@@ -308,6 +325,18 @@ func (s *atlasStream) Close() error {
|
||||
return s.body.Close()
|
||||
}
|
||||
|
||||
type atlascloudAPIError struct {
|
||||
Status string
|
||||
StatusCode int
|
||||
Phase string
|
||||
Summary string
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *atlascloudAPIError) Error() string {
|
||||
return fmt.Sprintf("API error (%s) during atlascloud %s request (%s): %s", e.Status, e.Phase, e.Summary, e.Body)
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -331,7 +360,7 @@ func (p *Provider) callAPI(ctx context.Context, phase string, req map[string]any
|
||||
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
return nil, nil, fmt.Errorf("API error (%s) during atlascloud %s request (%s): %s", httpResp.Status, phase, atlascloudRequestSummary(req), string(respBody))
|
||||
return nil, nil, &atlascloudAPIError{Status: httpResp.Status, StatusCode: httpResp.StatusCode, Phase: phase, Summary: atlascloudRequestSummary(req), Body: string(respBody)}
|
||||
}
|
||||
|
||||
var chatResp struct {
|
||||
@@ -376,6 +405,50 @@ func (p *Provider) callAPI(ctx context.Context, phase string, req map[string]any
|
||||
return response, rawMessage, nil
|
||||
}
|
||||
|
||||
func atlascloudMinimaxCompatTools(model string, input []ai.Tool) ([]map[string]any, string) {
|
||||
if !atlascloudIsMinimaxModel(model) || len(input) == 0 {
|
||||
return nil, ""
|
||||
}
|
||||
var native []ai.Tool
|
||||
var builtins []string
|
||||
for _, tool := range input {
|
||||
switch tool.Name {
|
||||
case "plan", "request_input", "delegate":
|
||||
builtins = append(builtins, tool.Name)
|
||||
default:
|
||||
native = append(native, tool)
|
||||
}
|
||||
}
|
||||
if len(builtins) == 0 || len(native) == len(input) {
|
||||
return nil, ""
|
||||
}
|
||||
prompt := "AtlasCloud/minimax compatibility: use native tool_calls for the listed service tools. " +
|
||||
"For built-in agent tools that are not listed natively (" + strings.Join(builtins, ", ") +
|
||||
"), emit exactly <tool_call name=\"tool_name\">{...}</tool_call> so the agent runtime can execute them. Do not describe those built-in tool calls in prose instead of emitting the tag."
|
||||
return atlascloudTools(native), prompt
|
||||
}
|
||||
|
||||
func atlascloudIsMinimaxModel(model string) bool {
|
||||
model = strings.ToLower(model)
|
||||
return strings.Contains(model, "minimax")
|
||||
}
|
||||
|
||||
func atlascloudShouldRetryMinimaxCompat(err error, compatTools []map[string]any) bool {
|
||||
if len(compatTools) == 0 {
|
||||
return false
|
||||
}
|
||||
var apiErr *atlascloudAPIError
|
||||
return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusBadRequest
|
||||
}
|
||||
|
||||
func atlascloudShouldRetryWithoutTools(err error, req map[string]any) bool {
|
||||
if _, ok := req["tools"]; !ok {
|
||||
return false
|
||||
}
|
||||
var apiErr *atlascloudAPIError
|
||||
return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusBadRequest
|
||||
}
|
||||
|
||||
func atlascloudTools(input []ai.Tool) []map[string]any {
|
||||
tools := make([]map[string]any, 0, len(input))
|
||||
for _, t := range input {
|
||||
|
||||
@@ -454,6 +454,120 @@ func TestProvider_GeneratePreservesFollowUpTextToolCallInReply(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_GenerateRetriesMinimaxBuiltInsAsTextTools(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:
|
||||
http.Error(w, `{"code":400,"msg":"bad request"}`, http.StatusBadRequest)
|
||||
case 2:
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"<tool_call name=\"delegate\">{\"task\":\"summarize\",\"to\":\"blocked-reviewer\"}</tool_call>"}}]}`))
|
||||
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"))
|
||||
resp, err := p.Generate(context.Background(), &ai.Request{
|
||||
Prompt: "plan and delegate",
|
||||
Tools: []ai.Tool{
|
||||
{Name: "task_TaskService_Add", Description: "add task", Properties: map[string]any{"title": map[string]any{"type": "string"}}},
|
||||
{Name: "plan", Description: "record a plan", Properties: map[string]any{"steps": map[string]any{"type": "array"}}},
|
||||
{Name: "request_input", Description: "request input", Properties: map[string]any{"prompt": map[string]any{"type": "string"}}},
|
||||
{Name: "delegate", Description: "delegate work", Properties: map[string]any{"task": map[string]any{"type": "string"}, "to": map[string]any{"type": "string"}}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(resp.Reply, `<tool_call name="delegate">`) {
|
||||
t.Fatalf("Reply = %q, want text delegate fallback", resp.Reply)
|
||||
}
|
||||
if len(bodies) != 2 {
|
||||
t.Fatalf("requests = %d, want initial plus compat retry", len(bodies))
|
||||
}
|
||||
initialTools := bodies[0]["tools"].([]any)
|
||||
if len(initialTools) != 4 {
|
||||
t.Fatalf("initial tools = %d, want all tools", len(initialTools))
|
||||
}
|
||||
retryTools := bodies[1]["tools"].([]any)
|
||||
if len(retryTools) != 1 {
|
||||
t.Fatalf("retry tools = %d, want only service tools", len(retryTools))
|
||||
}
|
||||
fn := retryTools[0].(map[string]any)["function"].(map[string]any)
|
||||
if fn["name"] != "task_TaskService_Add" {
|
||||
t.Fatalf("retry tool name = %v, want service tool only", fn["name"])
|
||||
}
|
||||
msgs := bodies[1]["messages"].([]any)
|
||||
compat := msgs[len(msgs)-1].(map[string]any)
|
||||
if compat["role"] != "system" || !strings.Contains(compat["content"].(string), `<tool_call name="tool_name">`) {
|
||||
t.Fatalf("compat instruction = %#v", compat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_GenerateFollowUpRetriesWithoutToolsOnBadRequest(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:
|
||||
http.Error(w, `{"code":400,"msg":"bad request"}`, http.StatusBadRequest)
|
||||
case 3:
|
||||
if _, ok := body["tools"]; ok {
|
||||
t.Fatalf("no-tools retry still included tools: %#v", body["tools"])
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"done"}}]}`))
|
||||
default:
|
||||
t.Fatalf("unexpected API call %d", len(bodies))
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
var toolCalls int
|
||||
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 {
|
||||
toolCalls++
|
||||
return ai.ToolResult{ID: call.ID, Content: `{"marker":"agent-conformance-ok"}`}
|
||||
}),
|
||||
)
|
||||
resp, err := p.Generate(context.Background(), &ai.Request{
|
||||
Prompt: "call a tool",
|
||||
Tools: []ai.Tool{{Name: "conformance_echo", Description: "echo", 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 toolCalls != 1 {
|
||||
t.Fatalf("tool handler calls = %d, want one (no duplicate side effect)", toolCalls)
|
||||
}
|
||||
if len(bodies) != 3 {
|
||||
t.Fatalf("requests = %d, want chat, failed follow-up, no-tools follow-up", len(bodies))
|
||||
}
|
||||
if _, ok := bodies[1]["tools"]; !ok {
|
||||
t.Fatalf("first follow-up did not include tools")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user