Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e9b268a5e3 | |||
| 1c8d8f0ff6 | |||
| a24eaad1c9 | |||
| 357fdf2777 | |||
| a66fb4ff2c | |||
| 8e3ba68d58 | |||
| 6e04f1afb5 | |||
| 110cb44d41 | |||
| f06e7467ce | |||
| 412491568f | |||
| d2e1520a14 | |||
| 0a18ac6c15 | |||
| 45a23a3417 | |||
| ae239b0102 | |||
| 3a2d21f1ac | |||
| 40a559e8db | |||
| 28320fc1d4 |
@@ -29,6 +29,14 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # need full history + all tags
|
||||
# Do NOT persist the default GITHUB_TOKEN as a git credential.
|
||||
# actions/checkout otherwise sets an http.extraheader Authorization
|
||||
# for github.com that is sent on ALL pushes — including our manual
|
||||
# PAT push below — and overrides the PAT, so the tag push
|
||||
# authenticates as github-actions[bot] and 403s (the job only has
|
||||
# contents: read). With this off, the PAT embedded in the push URL
|
||||
# is the only credential. (Fixes run 28554612450.)
|
||||
persist-credentials: false
|
||||
- name: Cut the next patch release if there are new commits
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN }}
|
||||
|
||||
@@ -62,6 +62,7 @@ examples/mcp/hello/hello
|
||||
/plan-delegate
|
||||
/agent-plan-delegate
|
||||
/micro-mcp-gateway
|
||||
/agent-ollama
|
||||
|
||||
# Local Jekyll / Bundler artifacts
|
||||
internal/website/.bundle/
|
||||
|
||||
@@ -17,6 +17,14 @@ next version when it ships.
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **`micro loop`** — scaffold an autonomous improvement loop into any repository: GitHub Actions workflows for a planner (keeps a ranked queue), builder (builds the top item as a single-concern PR, auto-merged on green CI), and triage (turns CI failures into fix issues), dispatched to an @mention-driven coding agent. `micro loop init` writes the workflows + `NORTH_STAR`/`PRIORITIES`; `micro loop verify` checks the wiring. This is the loop that maintains go-micro itself, generalized. (`cmd/micro/loop/`)
|
||||
|
||||
---
|
||||
|
||||
## [6.3.12] - July 2026
|
||||
|
||||
### Added
|
||||
- **Ollama provider** — run agents against open-weight models locally (`/api/chat`, NDJSON streaming) or via Ollama Cloud (OpenAI-compatible `/v1/chat/completions`, SSE), auto-detected from the base URL, with tool calling in both modes. Point any agent at a non-default endpoint with the new `agent.BaseURL` / `micro.AgentBaseURL` option. (`ai/ollama/`, `examples/agent-ollama/`)
|
||||
- **Retrieval-backed agent memory** — agents can recall relevant prior turns by similarity, not just the recent window, with a summarizer hook that compacts older history so long conversations stay in budget. (`agent/`)
|
||||
- **Scheduled flows** — a flow can run an agent (or any step) on a cron-style schedule, with the dispatch traced end to end. (`flow/`)
|
||||
- **Flow verification/grader loop** — a workflow can grade its own step output against a rubric and retry until it passes, plus run-trace analysis to surface where a flow spends its time. (`flow/`)
|
||||
|
||||
@@ -8,7 +8,7 @@ LDFLAGS = -X $(GIT_IMPORT).BuildDate=$(BUILD_DATE) -X $(GIT_IMPORT).GitCommit=$(
|
||||
# GORELEASER_DOCKER_IMAGE = ghcr.io/goreleaser/goreleaser-cross:v1.25.7
|
||||
GORELEASER_DOCKER_IMAGE = ghcr.io/goreleaser/goreleaser:latest
|
||||
|
||||
.PHONY: test test-race test-coverage harness provider-conformance-mock provider-conformance lint fmt install-tools proto clean help gorelease-dry-run gorelease-dry-run-docker
|
||||
.PHONY: test test-race test-coverage harness install-smoke provider-conformance-mock provider-conformance lint fmt install-tools proto clean help gorelease-dry-run gorelease-dry-run-docker
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@@ -19,6 +19,7 @@ help:
|
||||
@echo " make test-coverage - Run tests with coverage"
|
||||
@echo " make lint - Run linter"
|
||||
@echo " make harness - Run deterministic getting-started and end-to-end harnesses"
|
||||
@echo " make install-smoke - Verify the local install.sh and first-run CLI smoke path"
|
||||
@echo " make provider-conformance-mock - Run cross-provider harness with deterministic mock provider"
|
||||
@echo " make provider-conformance - Run harnesses against configured live providers"
|
||||
@echo " make fmt - Format code"
|
||||
@@ -48,11 +49,17 @@ test-coverage:
|
||||
# This mirrors the default CI path so local dogfooding catches scaffold,
|
||||
# run/chat/inspect, and 0→hero regressions before a PR is opened.
|
||||
harness:
|
||||
$(MAKE) install-smoke
|
||||
go test ./cmd/micro/cli/new -run TestZeroToOne -count=1
|
||||
./internal/harness/zero-to-hero-ci/run.sh
|
||||
go run ./internal/harness/agent-flow
|
||||
$(MAKE) provider-conformance-mock
|
||||
|
||||
# Verify the documented install script and first-run CLI command boundaries without
|
||||
# provider keys or network access.
|
||||
install-smoke:
|
||||
./internal/harness/install-smoke/run.sh
|
||||
|
||||
# Run the shared provider conformance contract with the deterministic mock
|
||||
# provider. This is the no-secret path used by CI and local dogfooding to keep
|
||||
# provider-facing agent/tool semantics covered on every machine.
|
||||
|
||||
@@ -25,6 +25,7 @@ Running Go Micro in production, or building on it and want help? Paid **support,
|
||||
## Contents
|
||||
|
||||
- [Quick Start](#quick-start)
|
||||
- [First agent on-ramp](#first-agent-on-ramp)
|
||||
- [Why an Agent Harness](#why-an-agent-harness)
|
||||
- [Writing Services](#writing-services)
|
||||
- [Building Agents](#building-agents) — [Plan & Delegate](#plan--delegate), [Pluggable](#batteries-included-pluggable), [Paid tools (x402)](#paid-tools-x402), [A2A](#reachable-by-other-agents-a2a)
|
||||
@@ -66,14 +67,35 @@ curl -X POST http://localhost:8080/api/helloworld/Helloworld.Call \
|
||||
-H 'Content-Type: application/json' -d '{"name":"World"}'
|
||||
```
|
||||
|
||||
This scaffold → run → call path is covered by the no-secret CI harness. To run
|
||||
the same local contract (including the [0→hero services → agents → workflows path](internal/website/docs/guides/zero-to-hero.md),
|
||||
This install → scaffold → run → call path is covered by no-secret CI harnesses. To
|
||||
verify just the local installer and first-run CLI boundaries without network
|
||||
access or provider keys, use:
|
||||
|
||||
```bash
|
||||
make install-smoke
|
||||
```
|
||||
|
||||
To run the broader local contract (including the [0→hero services → agents → workflows path](internal/website/docs/guides/zero-to-hero.md),
|
||||
chat/inspect CLI boundaries, and deploy dry-run), use:
|
||||
|
||||
```bash
|
||||
make harness
|
||||
```
|
||||
|
||||
### First agent on-ramp
|
||||
|
||||
After install and the first `micro new`/`micro run` smoke check, take the
|
||||
walkable agent path in this order:
|
||||
|
||||
1. [Your First Agent](internal/website/docs/guides/your-first-agent.md) — build a
|
||||
service-backed agent and talk to it with `micro chat`.
|
||||
2. [Debugging your agent](internal/website/docs/guides/debugging-agents.md) — use
|
||||
`micro agent inspect`, run history, memory, and provider checks when the first
|
||||
conversation does something unexpected.
|
||||
3. [0→hero Reference](internal/website/docs/guides/zero-to-hero.md) — complete the
|
||||
services → agents → workflows loop with scaffold, run, chat, inspect, flow
|
||||
history, and deploy dry-run commands that match the maintained harness.
|
||||
|
||||
### Generate from a prompt — with an LLM key
|
||||
|
||||
Set a provider key, describe what you want, and the AI designs services, writes handlers, compiles, and starts them:
|
||||
@@ -308,7 +330,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, …) |
|
||||
| 7 LLM providers | Anthropic, OpenAI, Gemini, Groq, Mistral, Together, Atlas Cloud |
|
||||
| 8 LLM providers | Anthropic, OpenAI, Gemini, Groq, Mistral, Together, Atlas Cloud, 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 |
|
||||
|
||||
@@ -405,6 +427,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` |
|
||||
| Ollama | `llama3.2` (local) |
|
||||
|
||||
```go
|
||||
m := ai.New("anthropic", ai.WithAPIKey(key))
|
||||
|
||||
@@ -34,6 +34,7 @@ import (
|
||||
_ "go-micro.dev/v6/ai/gemini"
|
||||
_ "go-micro.dev/v6/ai/groq"
|
||||
_ "go-micro.dev/v6/ai/mistral"
|
||||
_ "go-micro.dev/v6/ai/ollama"
|
||||
_ "go-micro.dev/v6/ai/openai"
|
||||
_ "go-micro.dev/v6/ai/together"
|
||||
)
|
||||
@@ -149,6 +150,9 @@ func (a *agentImpl) setupWithToolHandler(handler ai.ToolHandler) {
|
||||
if a.opts.Model != "" {
|
||||
modelOpts = append(modelOpts, ai.WithModel(a.opts.Model))
|
||||
}
|
||||
if a.opts.BaseURL != "" {
|
||||
modelOpts = append(modelOpts, ai.WithBaseURL(a.opts.BaseURL))
|
||||
}
|
||||
|
||||
// Reuse the existing tools instance: its name map is populated by
|
||||
// discoverTools, and rebuilding it here would orphan a base handler that
|
||||
|
||||
+35
-1
@@ -290,7 +290,11 @@ func (a *agentImpl) planWrap(next ai.ToolHandler) ai.ToolHandler {
|
||||
if call.Name == toolPlan {
|
||||
return a.handlePlan(call)
|
||||
}
|
||||
return next(ctx, call)
|
||||
res := next(ctx, call)
|
||||
if res.Refused == "" && toolErrorMessage(res) == "" {
|
||||
a.completeNextPlanStep()
|
||||
}
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,6 +372,36 @@ func (a *agentImpl) handlePlan(call ai.ToolCall) ai.ToolResult {
|
||||
return ai.ToolResult{ID: call.ID, Value: call.Input, Content: string(data)}
|
||||
}
|
||||
|
||||
func (a *agentImpl) completeNextPlanStep() {
|
||||
plan := a.loadPlan()
|
||||
if plan == "" {
|
||||
return
|
||||
}
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(plan), &data); err != nil {
|
||||
return
|
||||
}
|
||||
steps, ok := data["steps"].([]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, raw := range steps {
|
||||
step, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
status, _ := step["status"].(string)
|
||||
if status == "" || status == "pending" || status == "in_progress" {
|
||||
step["status"] = "done"
|
||||
b, err := json.Marshal(data)
|
||||
if err == nil {
|
||||
_ = a.stateStore().Write(&store.Record{Key: planKey, Value: b})
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleHumanInput records that the model needs operator input before it can continue.
|
||||
func (a *agentImpl) handleHumanInput(call ai.ToolCall) ai.ToolResult {
|
||||
prompt, _ := call.Input["prompt"].(string)
|
||||
|
||||
@@ -102,6 +102,45 @@ func TestResumeFailedCheckpointDoesNotReplayCompletedTool(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckpointSkipsDuplicateToolWithinAsk(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "tool-dedupe-agent")
|
||||
toolRuns := 0
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
if opts.ToolHandler == nil {
|
||||
t.Fatal("missing tool handler")
|
||||
}
|
||||
opts.ToolHandler(ctx, ai.ToolCall{ID: "plan-1", Name: toolPlan, Input: map[string]any{
|
||||
"steps": []any{
|
||||
map[string]any{"task": "create Design task", "status": "pending"},
|
||||
},
|
||||
}})
|
||||
for i := 0; i < 3; i++ {
|
||||
res := opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "external.create", Input: map[string]any{"title": "Design"}})
|
||||
if res.Content != "created Design" {
|
||||
t.Fatalf("tool result %d = %q, want cached created Design", i, res.Content)
|
||||
}
|
||||
}
|
||||
return &ai.Response{Reply: "done"}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(Name("tool-dedupe-agent"), WithCheckpoint(cp),
|
||||
WithTool("external.create", "create once", nil, func(context.Context, map[string]any) (string, error) {
|
||||
toolRuns++
|
||||
return "created Design", nil
|
||||
}))
|
||||
if _, err := a.Ask(ctx, "create Design once"); err != nil {
|
||||
t.Fatalf("Ask: %v", err)
|
||||
}
|
||||
if toolRuns != 1 {
|
||||
t.Fatalf("tool executions = %d, want duplicate calls within the run replayed from checkpoint", toolRuns)
|
||||
}
|
||||
if plan := a.loadPlan(); !strings.Contains(plan, `"status":"done"`) {
|
||||
t.Fatalf("plan = %s, want completed action marked done", plan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeFailedCheckpointAfterFreshAgentRestart(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "restart-resume-agent")
|
||||
|
||||
@@ -40,6 +40,7 @@ type Options struct {
|
||||
Provider string
|
||||
Model string
|
||||
APIKey string
|
||||
BaseURL string
|
||||
Address string
|
||||
Registry registry.Registry
|
||||
Client client.Client
|
||||
@@ -168,6 +169,12 @@ func APIKey(k string) Option {
|
||||
return func(o *Options) { o.APIKey = k }
|
||||
}
|
||||
|
||||
// BaseURL sets the base URL for the LLM provider. Use this to point
|
||||
// the provider at a non-default endpoint (e.g., local Ollama, a proxy).
|
||||
func BaseURL(url string) Option {
|
||||
return func(o *Options) { o.BaseURL = url }
|
||||
}
|
||||
|
||||
// Address sets the network address for the agent's service endpoint.
|
||||
// Use "127.0.0.1:0" in local harnesses/tests to bind an ephemeral loopback
|
||||
// port and avoid advertising the default service address.
|
||||
|
||||
@@ -0,0 +1,729 @@
|
||||
// Package ollama implements the Ollama model provider.
|
||||
//
|
||||
// Ollama runs open-weight models locally (or via Ollama Cloud). This
|
||||
// provider supports two API styles:
|
||||
//
|
||||
// - Native (/api/chat): local Ollama servers (default, http://localhost:11434)
|
||||
// - OpenAI-compatible (/v1/chat/completions): Ollama Cloud (https://ollama.com/v1)
|
||||
//
|
||||
// The provider auto-detects which style to use based on the base URL.
|
||||
// Set OLLAMA_BASE_URL to point at your server (local or cloud).
|
||||
//
|
||||
// Usage (local):
|
||||
//
|
||||
// import _ "go-micro.dev/v6/ai/ollama"
|
||||
//
|
||||
// m := ai.New("ollama",
|
||||
// ai.WithBaseURL("http://localhost:11434"),
|
||||
// ai.WithModel("llama3.2"),
|
||||
// )
|
||||
//
|
||||
// Usage (Ollama Cloud):
|
||||
//
|
||||
// m := ai.New("ollama",
|
||||
// ai.WithBaseURL("https://ollama.com/v1"),
|
||||
// ai.WithAPIKey("your-key"),
|
||||
// ai.WithModel("gpt-oss:120b"),
|
||||
// )
|
||||
package ollama
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
)
|
||||
|
||||
func init() {
|
||||
ai.Register("ollama", func(opts ...ai.Option) ai.Model {
|
||||
return NewProvider(opts...)
|
||||
})
|
||||
ai.RegisterStream("ollama")
|
||||
}
|
||||
|
||||
// Provider implements the ai.Model interface for Ollama.
|
||||
type Provider struct {
|
||||
opts ai.Options
|
||||
|
||||
// cloudOverride forces cloud mode for testing. When true, the provider
|
||||
// uses the OpenAI-compatible endpoint regardless of the base URL.
|
||||
cloudOverride bool
|
||||
}
|
||||
|
||||
// NewProvider creates a new Ollama provider.
|
||||
func NewProvider(opts ...ai.Option) *Provider {
|
||||
options := ai.NewOptions(opts...)
|
||||
if options.Model == "" {
|
||||
options.Model = "llama3.2"
|
||||
}
|
||||
if options.BaseURL == "" {
|
||||
options.BaseURL = "http://localhost:11434"
|
||||
}
|
||||
return &Provider{opts: options}
|
||||
}
|
||||
|
||||
// Init initializes the provider with options.
|
||||
func (p *Provider) Init(opts ...ai.Option) error {
|
||||
for _, o := range opts {
|
||||
o(&p.opts)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Options returns the provider options.
|
||||
func (p *Provider) Options() ai.Options { return p.opts }
|
||||
|
||||
// String returns the provider name.
|
||||
func (p *Provider) String() string { return "ollama" }
|
||||
|
||||
// isCloud returns true when the base URL points at Ollama Cloud (ollama.com),
|
||||
// which uses the OpenAI-compatible /v1/chat/completions endpoint instead of
|
||||
// the native /api/chat.
|
||||
func (p *Provider) isCloud() bool {
|
||||
if p.cloudOverride {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(p.opts.BaseURL, "ollama.com")
|
||||
}
|
||||
|
||||
// chatPath returns the API endpoint path for chat completions.
|
||||
func (p *Provider) chatPath() string {
|
||||
if p.isCloud() {
|
||||
return "/v1/chat/completions"
|
||||
}
|
||||
return "/api/chat"
|
||||
}
|
||||
|
||||
// streamPath returns the API endpoint path for streaming chat.
|
||||
// Ollama Cloud uses the same /v1/chat/completions with stream:true.
|
||||
// Local Ollama uses /api/chat with stream:true.
|
||||
func (p *Provider) streamPath() string {
|
||||
return p.chatPath()
|
||||
}
|
||||
|
||||
// Generate generates a response from the Ollama model.
|
||||
func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
|
||||
if p.isCloud() {
|
||||
return p.generateOpenAI(ctx, req)
|
||||
}
|
||||
return p.generateNative(ctx, req)
|
||||
}
|
||||
|
||||
// Stream generates a streaming response.
|
||||
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
|
||||
if p.isCloud() {
|
||||
return p.streamOpenAI(ctx, req)
|
||||
}
|
||||
return p.streamNative(ctx, req)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OpenAI-compatible mode (Ollama Cloud: ollama.com/v1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (p *Provider) generateOpenAI(ctx context.Context, req *ai.Request) (*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 := buildOpenAIMessages(req)
|
||||
apiReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"messages": messages,
|
||||
"stream": false,
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
apiReq["tools"] = tools
|
||||
}
|
||||
if p.opts.MaxTokens > 0 {
|
||||
apiReq["max_tokens"] = p.opts.MaxTokens
|
||||
}
|
||||
|
||||
resp, rawMsg, err := p.callOpenAI(ctx, apiReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// No tool calls or no handler — return as-is.
|
||||
if len(resp.ToolCalls) == 0 || p.opts.ToolHandler == nil {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Tool execution loop.
|
||||
convMessages := append(messages, map[string]any{
|
||||
"role": "assistant",
|
||||
"content": rawMsg.content,
|
||||
"tool_calls": rawMsg.toolCalls,
|
||||
})
|
||||
|
||||
pendingCalls := resp.ToolCalls
|
||||
for round := 0; round < 10; round++ {
|
||||
for i := range pendingCalls {
|
||||
result := p.opts.ToolHandler(ctx, pendingCalls[i])
|
||||
pendingCalls[i].Result = result.Content
|
||||
convMessages = append(convMessages, map[string]any{
|
||||
"role": "tool",
|
||||
"tool_call_id": pendingCalls[i].ID,
|
||||
"content": result.Content,
|
||||
})
|
||||
}
|
||||
|
||||
followUpReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"messages": convMessages,
|
||||
"stream": false,
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
followUpReq["tools"] = tools
|
||||
}
|
||||
if p.opts.MaxTokens > 0 {
|
||||
followUpReq["max_tokens"] = p.opts.MaxTokens
|
||||
}
|
||||
|
||||
followUpResp, followUpRaw, err := p.callOpenAI(ctx, followUpReq)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
if len(followUpResp.ToolCalls) > 0 {
|
||||
resp.ToolCalls = append(resp.ToolCalls, followUpResp.ToolCalls...)
|
||||
pendingCalls = followUpResp.ToolCalls
|
||||
convMessages = append(convMessages, map[string]any{
|
||||
"role": "assistant",
|
||||
"content": followUpRaw.content,
|
||||
"tool_calls": followUpRaw.toolCalls,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if followUpResp.Reply != "" {
|
||||
resp.Answer = followUpResp.Reply
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (p *Provider) callOpenAI(ctx context.Context, req map[string]any) (*ai.Response, *rawChatMessage, 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, "/") + p.chatPath()
|
||||
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")
|
||||
if p.opts.APIKey != "" {
|
||||
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 {
|
||||
Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
} `json:"usage"`
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Role string `json:"role"`
|
||||
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,
|
||||
Usage: ai.Usage{
|
||||
InputTokens: chatResp.Usage.PromptTokens,
|
||||
OutputTokens: chatResp.Usage.CompletionTokens,
|
||||
TotalTokens: chatResp.Usage.TotalTokens,
|
||||
},
|
||||
}
|
||||
|
||||
var rawToolCalls []map[string]any
|
||||
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,
|
||||
})
|
||||
rawToolCalls = append(rawToolCalls, map[string]any{
|
||||
"id": tc.ID,
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": tc.Function.Name,
|
||||
"arguments": tc.Function.Arguments,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
raw := &rawChatMessage{
|
||||
content: choice.Message.Content,
|
||||
toolCalls: rawToolCalls,
|
||||
}
|
||||
return response, raw, nil
|
||||
}
|
||||
|
||||
func (p *Provider) streamOpenAI(ctx context.Context, req *ai.Request) (ai.Stream, error) {
|
||||
messages := buildOpenAIMessages(req)
|
||||
apiReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"messages": messages,
|
||||
"stream": true,
|
||||
"stream_options": map[string]any{"include_usage": true},
|
||||
}
|
||||
if p.opts.MaxTokens > 0 {
|
||||
apiReq["max_tokens"] = p.opts.MaxTokens
|
||||
}
|
||||
|
||||
reqBody, err := json.Marshal(apiReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal stream request: %w", err)
|
||||
}
|
||||
|
||||
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + p.streamPath()
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create stream request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Accept", "text/event-stream")
|
||||
if p.opts.APIKey != "" {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
|
||||
}
|
||||
|
||||
httpResp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stream API request failed: %w", err)
|
||||
}
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
defer httpResp.Body.Close()
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
return nil, fmt.Errorf("stream API error (%s): %s", httpResp.Status, string(respBody))
|
||||
}
|
||||
|
||||
return &sseStream{body: httpResp.Body, scanner: bufio.NewScanner(httpResp.Body)}, nil
|
||||
}
|
||||
|
||||
// buildOpenAIMessages converts an ai.Request into the OpenAI chat message format.
|
||||
func buildOpenAIMessages(req *ai.Request) []map[string]any {
|
||||
messages := []map[string]any{}
|
||||
if req.SystemPrompt != "" {
|
||||
messages = append(messages, map[string]any{"role": "system", "content": req.SystemPrompt})
|
||||
}
|
||||
for _, m := range req.Messages {
|
||||
messages = append(messages, map[string]any{"role": m.Role, "content": m.Content})
|
||||
}
|
||||
if req.Prompt != "" {
|
||||
messages = append(messages, map[string]any{"role": "user", "content": req.Prompt})
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
// sseStream reads OpenAI-style server-sent events (used by Ollama Cloud).
|
||||
type sseStream struct {
|
||||
body io.ReadCloser
|
||||
scanner *bufio.Scanner
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (s *sseStream) Recv() (*ai.Response, error) {
|
||||
for s.scanner.Scan() {
|
||||
line := strings.TrimSpace(s.scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, ":") {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(line, "data:") {
|
||||
continue
|
||||
}
|
||||
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
if data == "[DONE]" {
|
||||
return nil, io.EOF
|
||||
}
|
||||
var chunk struct {
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"delta"`
|
||||
} `json:"choices"`
|
||||
Usage *struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse stream chunk: %w", err)
|
||||
}
|
||||
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
|
||||
return &ai.Response{Reply: chunk.Choices[0].Delta.Content}, nil
|
||||
}
|
||||
if chunk.Usage != nil {
|
||||
return &ai.Response{Usage: ai.Usage{
|
||||
InputTokens: chunk.Usage.PromptTokens,
|
||||
OutputTokens: chunk.Usage.CompletionTokens,
|
||||
TotalTokens: chunk.Usage.TotalTokens,
|
||||
}}, nil
|
||||
}
|
||||
}
|
||||
if err := s.scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, io.EOF
|
||||
}
|
||||
|
||||
func (s *sseStream) Close() error {
|
||||
if s.closed {
|
||||
return nil
|
||||
}
|
||||
s.closed = true
|
||||
return s.body.Close()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Native mode (local Ollama: localhost:11434/api/chat)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (p *Provider) generateNative(ctx context.Context, req *ai.Request) (*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{}
|
||||
if req.SystemPrompt != "" {
|
||||
messages = append(messages, map[string]any{"role": "system", "content": req.SystemPrompt})
|
||||
}
|
||||
for _, m := range req.Messages {
|
||||
messages = append(messages, map[string]any{"role": m.Role, "content": m.Content})
|
||||
}
|
||||
if req.Prompt != "" {
|
||||
messages = append(messages, map[string]any{"role": "user", "content": req.Prompt})
|
||||
}
|
||||
|
||||
apiReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"messages": messages,
|
||||
"stream": false,
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
apiReq["tools"] = tools
|
||||
}
|
||||
if p.opts.MaxTokens > 0 {
|
||||
apiReq["options"] = map[string]any{"num_predict": p.opts.MaxTokens}
|
||||
}
|
||||
|
||||
resp, rawMsg, err := p.callNative(ctx, apiReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(resp.ToolCalls) == 0 || p.opts.ToolHandler == nil {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
convMessages := append(messages, map[string]any{
|
||||
"role": "assistant",
|
||||
"content": rawMsg.content,
|
||||
})
|
||||
if len(rawMsg.toolCalls) > 0 {
|
||||
convMessages[len(convMessages)-1]["tool_calls"] = rawMsg.toolCalls
|
||||
}
|
||||
|
||||
pendingCalls := resp.ToolCalls
|
||||
for round := 0; round < 10; round++ {
|
||||
for i := range pendingCalls {
|
||||
result := p.opts.ToolHandler(ctx, pendingCalls[i])
|
||||
pendingCalls[i].Result = result.Content
|
||||
convMessages = append(convMessages, map[string]any{
|
||||
"role": "tool",
|
||||
"content": result.Content,
|
||||
})
|
||||
}
|
||||
|
||||
followUpReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"messages": convMessages,
|
||||
"stream": false,
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
followUpReq["tools"] = tools
|
||||
}
|
||||
if p.opts.MaxTokens > 0 {
|
||||
followUpReq["options"] = map[string]any{"num_predict": p.opts.MaxTokens}
|
||||
}
|
||||
|
||||
followUpResp, followUpRaw, err := p.callNative(ctx, followUpReq)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
if len(followUpResp.ToolCalls) > 0 {
|
||||
resp.ToolCalls = append(resp.ToolCalls, followUpResp.ToolCalls...)
|
||||
pendingCalls = followUpResp.ToolCalls
|
||||
convMessages = append(convMessages, map[string]any{
|
||||
"role": "assistant",
|
||||
"content": followUpRaw.content,
|
||||
})
|
||||
if len(followUpRaw.toolCalls) > 0 {
|
||||
convMessages[len(convMessages)-1]["tool_calls"] = followUpRaw.toolCalls
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if followUpResp.Reply != "" {
|
||||
resp.Answer = followUpResp.Reply
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (p *Provider) callNative(ctx context.Context, req map[string]any) (*ai.Response, *rawChatMessage, 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, "/") + p.chatPath()
|
||||
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")
|
||||
if p.opts.APIKey != "" {
|
||||
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 {
|
||||
Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
ToolCalls []struct {
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments any `json:"arguments"`
|
||||
} `json:"function"`
|
||||
} `json:"tool_calls"`
|
||||
} `json:"message"`
|
||||
Done bool `json:"done"`
|
||||
PromptEvalCount int `json:"prompt_eval_count"`
|
||||
EvalCount int `json:"eval_count"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(respBody, &chatResp); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
response := &ai.Response{
|
||||
Reply: chatResp.Message.Content,
|
||||
Usage: ai.Usage{
|
||||
InputTokens: chatResp.PromptEvalCount,
|
||||
OutputTokens: chatResp.EvalCount,
|
||||
TotalTokens: chatResp.PromptEvalCount + chatResp.EvalCount,
|
||||
},
|
||||
}
|
||||
|
||||
var rawToolCalls []map[string]any
|
||||
for _, tc := range chatResp.Message.ToolCalls {
|
||||
var input map[string]any
|
||||
switch v := tc.Function.Arguments.(type) {
|
||||
case string:
|
||||
if err := json.Unmarshal([]byte(v), &input); err != nil {
|
||||
input = map[string]any{}
|
||||
}
|
||||
case map[string]any:
|
||||
input = v
|
||||
default:
|
||||
input = map[string]any{}
|
||||
}
|
||||
response.ToolCalls = append(response.ToolCalls, ai.ToolCall{
|
||||
Name: tc.Function.Name,
|
||||
Input: input,
|
||||
})
|
||||
rawToolCalls = append(rawToolCalls, map[string]any{
|
||||
"function": map[string]any{
|
||||
"name": tc.Function.Name,
|
||||
"arguments": tc.Function.Arguments,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
raw := &rawChatMessage{
|
||||
content: chatResp.Message.Content,
|
||||
toolCalls: rawToolCalls,
|
||||
}
|
||||
return response, raw, nil
|
||||
}
|
||||
|
||||
func (p *Provider) streamNative(ctx context.Context, req *ai.Request) (ai.Stream, error) {
|
||||
messages := []map[string]any{}
|
||||
if req.SystemPrompt != "" {
|
||||
messages = append(messages, map[string]any{"role": "system", "content": req.SystemPrompt})
|
||||
}
|
||||
for _, m := range req.Messages {
|
||||
messages = append(messages, map[string]any{"role": m.Role, "content": m.Content})
|
||||
}
|
||||
if req.Prompt != "" {
|
||||
messages = append(messages, map[string]any{"role": "user", "content": req.Prompt})
|
||||
}
|
||||
|
||||
apiReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"messages": messages,
|
||||
"stream": true,
|
||||
}
|
||||
if p.opts.MaxTokens > 0 {
|
||||
apiReq["options"] = map[string]any{"num_predict": p.opts.MaxTokens}
|
||||
}
|
||||
|
||||
reqBody, err := json.Marshal(apiReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal stream request: %w", err)
|
||||
}
|
||||
|
||||
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + p.streamPath()
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create stream request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
if p.opts.APIKey != "" {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
|
||||
}
|
||||
|
||||
httpResp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stream API request failed: %w", err)
|
||||
}
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
defer httpResp.Body.Close()
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
return nil, fmt.Errorf("stream API error (%s): %s", httpResp.Status, string(respBody))
|
||||
}
|
||||
|
||||
return &ndjsonStream{body: httpResp.Body, scanner: bufio.NewScanner(httpResp.Body)}, nil
|
||||
}
|
||||
|
||||
// ndjsonStream reads newline-delimited JSON (used by local Ollama).
|
||||
type ndjsonStream struct {
|
||||
body io.ReadCloser
|
||||
scanner *bufio.Scanner
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (s *ndjsonStream) Recv() (*ai.Response, error) {
|
||||
for s.scanner.Scan() {
|
||||
line := strings.TrimSpace(s.scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var chunk struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
Done bool `json:"done"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &chunk); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse stream chunk: %w", err)
|
||||
}
|
||||
if chunk.Done {
|
||||
return nil, io.EOF
|
||||
}
|
||||
if chunk.Message.Content != "" {
|
||||
return &ai.Response{Reply: chunk.Message.Content}, nil
|
||||
}
|
||||
}
|
||||
if err := s.scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, io.EOF
|
||||
}
|
||||
|
||||
func (s *ndjsonStream) Close() error {
|
||||
if s.closed {
|
||||
return nil
|
||||
}
|
||||
s.closed = true
|
||||
return s.body.Close()
|
||||
}
|
||||
|
||||
// rawChatMessage holds the raw assistant content and tool calls for
|
||||
// follow-up messages.
|
||||
type rawChatMessage struct {
|
||||
content string
|
||||
toolCalls []map[string]any
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
package ollama
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider basics
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestProvider_String(t *testing.T) {
|
||||
p := NewProvider()
|
||||
if p.String() != "ollama" {
|
||||
t.Errorf("Expected 'ollama', got '%s'", p.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Init(t *testing.T) {
|
||||
p := NewProvider()
|
||||
err := p.Init(
|
||||
ai.WithModel("test-model"),
|
||||
ai.WithAPIKey("test-key"),
|
||||
ai.WithBaseURL("https://test.com"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Init failed: %v", err)
|
||||
}
|
||||
opts := p.Options()
|
||||
if opts.Model != "test-model" {
|
||||
t.Errorf("Expected model 'test-model', got '%s'", opts.Model)
|
||||
}
|
||||
if opts.APIKey != "test-key" {
|
||||
t.Errorf("Expected API key 'test-key', got '%s'", opts.APIKey)
|
||||
}
|
||||
if opts.BaseURL != "https://test.com" {
|
||||
t.Errorf("Expected base URL 'https://test.com', got '%s'", opts.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Defaults(t *testing.T) {
|
||||
p := NewProvider()
|
||||
opts := p.Options()
|
||||
if opts.Model != "llama3.2" {
|
||||
t.Errorf("Expected default model 'llama3.2', got '%s'", opts.Model)
|
||||
}
|
||||
if opts.BaseURL != "http://localhost:11434" {
|
||||
t.Errorf("Expected default base URL 'http://localhost:11434', got '%s'", opts.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_IsCloud(t *testing.T) {
|
||||
local := NewProvider(ai.WithBaseURL("http://localhost:11434"))
|
||||
if local.isCloud() {
|
||||
t.Error("localhost should not be cloud")
|
||||
}
|
||||
cloud := NewProvider(ai.WithBaseURL("https://ollama.com/v1"))
|
||||
if !cloud.isCloud() {
|
||||
t.Error("ollama.com should be cloud")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Native mode (local Ollama: /api/chat)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNative_Generate(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/chat" {
|
||||
t.Errorf("Expected /api/chat, got %s", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{
|
||||
"model": "llama3.2",
|
||||
"message": {"role": "assistant", "content": "Hello from local Ollama!"},
|
||||
"done": true,
|
||||
"prompt_eval_count": 10,
|
||||
"eval_count": 5
|
||||
}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewProvider(ai.WithBaseURL(srv.URL), ai.WithModel("llama3.2"))
|
||||
resp, err := p.Generate(context.Background(), &ai.Request{
|
||||
Prompt: "Hi",
|
||||
SystemPrompt: "You are helpful",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
if resp.Reply != "Hello from local Ollama!" {
|
||||
t.Errorf("Expected 'Hello from local Ollama!', got '%s'", resp.Reply)
|
||||
}
|
||||
if resp.Usage.TotalTokens != 15 {
|
||||
t.Errorf("Expected total tokens 15, got %d", resp.Usage.TotalTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNative_GenerateWithToolCall(t *testing.T) {
|
||||
callCount := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if callCount == 1 {
|
||||
w.Write([]byte(`{
|
||||
"model": "llama3.2",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [{"function": {"name": "get_weather", "arguments": "{\"city\":\"Seoul\"}"}}]
|
||||
},
|
||||
"done": true
|
||||
}`))
|
||||
} else {
|
||||
w.Write([]byte(`{
|
||||
"model": "llama3.2",
|
||||
"message": {"role": "assistant", "content": "The weather in Seoul is sunny."},
|
||||
"done": true
|
||||
}`))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
handler := func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
if call.Name != "get_weather" {
|
||||
t.Errorf("Expected tool 'get_weather', got '%s'", call.Name)
|
||||
}
|
||||
return ai.ToolResult{ID: call.ID, Content: `{"temp": 22, "condition": "sunny"}`}
|
||||
}
|
||||
|
||||
p := NewProvider(
|
||||
ai.WithBaseURL(srv.URL),
|
||||
ai.WithModel("llama3.2"),
|
||||
ai.WithToolHandler(handler),
|
||||
)
|
||||
resp, err := p.Generate(context.Background(), &ai.Request{
|
||||
Prompt: "What's the weather?",
|
||||
Tools: []ai.Tool{{
|
||||
Name: "get_weather",
|
||||
Description: "Get weather",
|
||||
Properties: map[string]any{"city": map[string]any{"type": "string"}},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
t.Error("Expected tool calls")
|
||||
}
|
||||
if resp.Answer != "The weather in Seoul is sunny." {
|
||||
t.Errorf("Expected final answer, got '%s'", resp.Answer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNative_Stream(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"message":{"role":"assistant","content":"Hello"},"done":false}` + "\n"))
|
||||
w.Write([]byte(`{"message":{"role":"assistant","content":" world"},"done":false}` + "\n"))
|
||||
w.Write([]byte(`{"message":{"role":"assistant","content":""},"done":true}` + "\n"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewProvider(ai.WithBaseURL(srv.URL), ai.WithModel("llama3.2"))
|
||||
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hi"})
|
||||
if err != nil {
|
||||
t.Fatalf("Stream failed: %v", err)
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
var chunks []string
|
||||
for {
|
||||
resp, err := stream.Recv()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if resp.Reply != "" {
|
||||
chunks = append(chunks, resp.Reply)
|
||||
}
|
||||
}
|
||||
result := strings.Join(chunks, "")
|
||||
if result != "Hello world" {
|
||||
t.Errorf("Expected 'Hello world', got '%s'", result)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cloud mode (Ollama Cloud: /v1/chat/completions)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCloud_Generate(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/chat/completions" {
|
||||
t.Errorf("Expected /v1/chat/completions, got %s", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
|
||||
"choices": [{"message": {"role": "assistant", "content": "Hello from Ollama Cloud!"}}]
|
||||
}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewProvider(ai.WithBaseURL(srv.URL), ai.WithModel("gemma4:31b-cloud"), ai.WithAPIKey("test-key"))
|
||||
p.cloudOverride = true
|
||||
resp, err := p.Generate(context.Background(), &ai.Request{
|
||||
Prompt: "Hi",
|
||||
SystemPrompt: "You are helpful",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
if resp.Reply != "Hello from Ollama Cloud!" {
|
||||
t.Errorf("Expected 'Hello from Ollama Cloud!', got '%s'", resp.Reply)
|
||||
}
|
||||
if resp.Usage.TotalTokens != 15 {
|
||||
t.Errorf("Expected total tokens 15, got %d", resp.Usage.TotalTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloud_GenerateWithToolCall(t *testing.T) {
|
||||
callCount := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if callCount == 1 {
|
||||
w.Write([]byte(`{
|
||||
"choices": [{"message": {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [{"id": "call_1", "function": {"name": "search", "arguments": "{\"query\":\"go interfaces\"}"}}]
|
||||
}}]
|
||||
}`))
|
||||
} else {
|
||||
w.Write([]byte(`{
|
||||
"choices": [{"message": {"role": "assistant", "content": "Go interfaces are implicit."}}]
|
||||
}`))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
handler := func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
return ai.ToolResult{ID: call.ID, Content: `{"results": ["Go interfaces are implicit"]}`}
|
||||
}
|
||||
|
||||
p := NewProvider(
|
||||
ai.WithBaseURL(srv.URL),
|
||||
ai.WithModel("gemma4:31b-cloud"),
|
||||
ai.WithAPIKey("test-key"),
|
||||
ai.WithToolHandler(handler),
|
||||
)
|
||||
p.cloudOverride = true
|
||||
resp, err := p.Generate(context.Background(), &ai.Request{
|
||||
Prompt: "Search for Go interfaces",
|
||||
Tools: []ai.Tool{{
|
||||
Name: "search",
|
||||
Description: "Search the knowledge base",
|
||||
Properties: map[string]any{"query": map[string]any{"type": "string"}},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
t.Error("Expected tool calls")
|
||||
}
|
||||
if resp.Answer != "Go interfaces are implicit." {
|
||||
t.Errorf("Expected final answer, got '%s'", resp.Answer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloud_Stream(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n"))
|
||||
w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\" cloud\"}}]}\n\n"))
|
||||
w.Write([]byte("data: [DONE]\n\n"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewProvider(
|
||||
ai.WithBaseURL(srv.URL),
|
||||
ai.WithModel("gemma4:31b-cloud"),
|
||||
ai.WithAPIKey("test-key"),
|
||||
)
|
||||
p.cloudOverride = true
|
||||
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hi"})
|
||||
if err != nil {
|
||||
t.Fatalf("Stream failed: %v", err)
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
var chunks []string
|
||||
for {
|
||||
resp, err := stream.Recv()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if resp.Reply != "" {
|
||||
chunks = append(chunks, resp.Reply)
|
||||
}
|
||||
}
|
||||
result := strings.Join(chunks, "")
|
||||
if result != "Hello cloud" {
|
||||
t.Errorf("Expected 'Hello cloud', got '%s'", result)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestProvider_APIError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"error": "model not found"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewProvider(ai.WithBaseURL(srv.URL), ai.WithModel("nonexistent"))
|
||||
_, err := p.Generate(context.Background(), &ai.Request{Prompt: "Hi"})
|
||||
if err == nil {
|
||||
t.Error("Expected error on API failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "API error") {
|
||||
t.Errorf("Expected 'API error' in message, got '%s'", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -625,3 +625,49 @@ Scopes provide fine-grained access control over which tokens can call which serv
|
||||
The gateway's scope system uses `auth.Account` from the go-micro framework. Scopes on accounts are the same `[]string` field used by the framework's `auth.Rules` and `wrapper/auth` package. The gateway stores scope requirements in the default store under `endpoint-scopes/<service>.<endpoint>` keys and checks them on every HTTP request.
|
||||
|
||||
For service-level (RPC) auth within the go-micro mesh, use the `wrapper/auth` package which provides `auth.Rules` with priority-based access control. See the [auth wrapper documentation](../../wrapper/auth/README.md) for details.
|
||||
|
||||
## Self-improving loop (`micro loop`)
|
||||
|
||||
Turn a repository into a self-improving one: GitHub Actions workflows that
|
||||
dispatch a coding agent to plan, build, and triage — gated by CI. This is the
|
||||
same loop that maintains go-micro itself, generalized so any repo (and any
|
||||
@mention-driven agent) can use it.
|
||||
|
||||
```bash
|
||||
micro loop init # scaffold the loop into the current repo
|
||||
micro loop verify # check a repo is wired correctly
|
||||
```
|
||||
|
||||
`micro loop init` writes three workflows and a queue:
|
||||
|
||||
| Role | File | What it does |
|
||||
|------|------|--------------|
|
||||
| Planner | `.github/workflows/loop-planner.yml` | Keeps a ranked queue in `.github/loop/PRIORITIES.md` |
|
||||
| Builder | `.github/workflows/loop-builder.yml` | Builds the top open item as a single-concern PR, auto-merged on green CI |
|
||||
| Triage | `.github/workflows/loop-triage.yml` | Turns CI failures into scoped fix issues, back into the queue |
|
||||
|
||||
Direction lives in `.github/loop/NORTH_STAR.md` — edit it to steer the loop.
|
||||
|
||||
Common flags:
|
||||
|
||||
```bash
|
||||
micro loop init \
|
||||
--agent @codex \
|
||||
--token-secret LOOP_TOKEN \
|
||||
--branch main \
|
||||
--ci-workflow CI
|
||||
```
|
||||
|
||||
- `--agent`: how the workflows summon the agent (an `@mention`)
|
||||
- `--token-secret`: repo secret holding the driving user PAT
|
||||
- `--branch`: base branch for the loop's PRs
|
||||
- `--ci-workflow`: `name:` of the CI workflow triage watches
|
||||
|
||||
Two things the CLI can't do for you (and `micro loop verify` reminds you of):
|
||||
|
||||
1. **Add the token secret.** The agent ignores `@mentions` from the
|
||||
`github-actions` bot, so dispatch posts as a real user via a PAT stored in
|
||||
the `--token-secret` repo secret. The workflows no-op until it's set.
|
||||
2. **Set branch protection.** Require the CI checks with **0 approving reviews**
|
||||
so the builder's native auto-merge lands PRs the moment CI is green — that
|
||||
green-CI gate is the loop's only safety mechanism, so keep the suite strong.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
microcmd "go-micro.dev/v6/cmd"
|
||||
)
|
||||
|
||||
func TestFirstAgentWalkthroughCLIBoundaries(t *testing.T) {
|
||||
commands := map[string]bool{}
|
||||
subcommands := map[string]map[string]bool{}
|
||||
for _, command := range microcmd.DefaultCmd.App().Commands {
|
||||
commands[command.Name] = true
|
||||
for _, subcommand := range command.Subcommands {
|
||||
if subcommands[command.Name] == nil {
|
||||
subcommands[command.Name] = map[string]bool{}
|
||||
}
|
||||
subcommands[command.Name][subcommand.Name] = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, want := range []string{"new", "run", "chat", "inspect", "agent"} {
|
||||
if !commands[want] {
|
||||
t.Fatalf("first-agent walkthrough missing %q command", want)
|
||||
}
|
||||
}
|
||||
if !subcommands["agent"]["preflight"] {
|
||||
t.Fatal("first-agent walkthrough missing preflight boundary: agent preflight")
|
||||
}
|
||||
if !subcommands["inspect"]["agent"] {
|
||||
t.Fatal("first-agent walkthrough missing inspect boundary: inspect agent")
|
||||
}
|
||||
|
||||
chat := commandByName(t, "chat")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func commandByName(t *testing.T, name string) *cli.Command {
|
||||
t.Helper()
|
||||
for _, command := range microcmd.DefaultCmd.App().Commands {
|
||||
if command.Name == name {
|
||||
return command
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing command %q", name)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
// Package loop implements the 'micro loop' command, which scaffolds and
|
||||
// verifies an autonomous improvement loop for a repository.
|
||||
//
|
||||
// The loop is a set of GitHub Actions workflows — a planner that keeps a ranked
|
||||
// queue, a builder that builds the top item as a single-concern PR, and a triage
|
||||
// pass that turns CI failures into fix issues — that dispatch a coding agent by
|
||||
// @mention on a fresh tracking issue each run. `micro loop init` writes those
|
||||
// workflows (plus a NORTH_STAR and PRIORITIES queue) into a repo; `micro loop
|
||||
// verify` checks that a repo is wired correctly.
|
||||
package loop
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"go-micro.dev/v6/cmd"
|
||||
)
|
||||
|
||||
//go:embed templates/*
|
||||
var templatesFS embed.FS
|
||||
|
||||
// config is the substitution surface for the workflow templates. It is the
|
||||
// whole "config vs core" boundary: the workflows are the reusable core, these
|
||||
// fields are what a given repo tunes.
|
||||
type config struct {
|
||||
DefaultBranch string // base branch for the loop's PRs (e.g. main)
|
||||
AgentMention string // how the workflows summon the agent (e.g. @codex)
|
||||
TokenSecret string // repo secret holding the user PAT that drives dispatch
|
||||
CIWorkflow string // name: of the CI workflow triage watches for failures
|
||||
PlannerCron string // cron for the planner
|
||||
BuilderCron string // cron for the builder
|
||||
}
|
||||
|
||||
// generated workflow files: template name -> destination (relative to repo root).
|
||||
var workflows = map[string]string{
|
||||
"templates/loop-planner.yml.tmpl": ".github/workflows/loop-planner.yml",
|
||||
"templates/loop-builder.yml.tmpl": ".github/workflows/loop-builder.yml",
|
||||
"templates/loop-triage.yml.tmpl": ".github/workflows/loop-triage.yml",
|
||||
}
|
||||
|
||||
// static (non-templated) docs: template name -> destination.
|
||||
var docs = map[string]string{
|
||||
"templates/NORTH_STAR.md": ".github/loop/NORTH_STAR.md",
|
||||
"templates/PRIORITIES.md": ".github/loop/PRIORITIES.md",
|
||||
}
|
||||
|
||||
func init() {
|
||||
cmd.Register(&cli.Command{
|
||||
Name: "loop",
|
||||
Usage: "Scaffold an autonomous improvement loop for a repository",
|
||||
Description: `Set up a self-improving loop for a repo: GitHub Actions workflows that
|
||||
dispatch a coding agent to plan, build, and triage — gated by CI.
|
||||
|
||||
The loop has three roles:
|
||||
planner keeps a ranked queue in .github/loop/PRIORITIES.md
|
||||
builder builds the top open item as a single-concern PR (auto-merged on green CI)
|
||||
triage turns CI failures into scoped fix issues back into the queue
|
||||
|
||||
Direction lives in .github/loop/NORTH_STAR.md — edit it to steer the loop.
|
||||
|
||||
Examples:
|
||||
# Scaffold the loop into the current repo
|
||||
micro loop init
|
||||
|
||||
# Customize the agent, token secret, base branch, and CI workflow name
|
||||
micro loop init --agent @codex --token-secret LOOP_TOKEN \
|
||||
--branch main --ci-workflow CI
|
||||
|
||||
# Check that a repo is wired correctly
|
||||
micro loop verify`,
|
||||
Subcommands: []*cli.Command{
|
||||
{
|
||||
Name: "init",
|
||||
Usage: "Scaffold the loop workflows and queue into a repo",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "dir", Usage: "Target repo directory", Value: "."},
|
||||
&cli.StringFlag{Name: "branch", Usage: "Base branch for the loop's PRs (auto-detected if empty)"},
|
||||
&cli.StringFlag{Name: "agent", Usage: "How the workflows summon the agent (an @mention)", Value: "@codex"},
|
||||
&cli.StringFlag{Name: "token-secret", Usage: "Repo secret holding the user PAT that drives dispatch", Value: "LOOP_TOKEN"},
|
||||
&cli.StringFlag{Name: "ci-workflow", Usage: "name: of the CI workflow triage watches for failures", Value: "CI"},
|
||||
&cli.StringFlag{Name: "planner-cron", Usage: "Cron schedule for the planner", Value: "0 * * * *"},
|
||||
&cli.StringFlag{Name: "builder-cron", Usage: "Cron schedule for the builder", Value: "30 * * * *"},
|
||||
&cli.BoolFlag{Name: "force", Usage: "Overwrite existing loop files"},
|
||||
},
|
||||
Action: runInit,
|
||||
},
|
||||
{
|
||||
Name: "verify",
|
||||
Usage: "Verify a repo is wired for the loop",
|
||||
Flags: []cli.Flag{&cli.StringFlag{Name: "dir", Usage: "Target repo directory", Value: "."}},
|
||||
Action: runVerify,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func runInit(c *cli.Context) error {
|
||||
dir := c.String("dir")
|
||||
cfg := config{
|
||||
DefaultBranch: c.String("branch"),
|
||||
AgentMention: strings.TrimSpace(c.String("agent")),
|
||||
TokenSecret: strings.TrimSpace(c.String("token-secret")),
|
||||
CIWorkflow: c.String("ci-workflow"),
|
||||
PlannerCron: c.String("planner-cron"),
|
||||
BuilderCron: c.String("builder-cron"),
|
||||
}
|
||||
if cfg.DefaultBranch == "" {
|
||||
cfg.DefaultBranch = detectDefaultBranch(dir)
|
||||
}
|
||||
if !strings.HasPrefix(cfg.AgentMention, "@") {
|
||||
cfg.AgentMention = "@" + cfg.AgentMention
|
||||
}
|
||||
|
||||
if err := scaffold(dir, cfg, c.Bool("force")); err != nil {
|
||||
return err
|
||||
}
|
||||
printNextSteps(cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
// scaffold renders the workflow templates and writes the loop files into dir.
|
||||
// Static docs (NORTH_STAR, PRIORITIES) are never clobbered even with force, so
|
||||
// re-running init can't wipe curated direction or a hand-tuned queue.
|
||||
func scaffold(dir string, cfg config, force bool) error {
|
||||
for tmplName, dest := range workflows {
|
||||
rendered, err := render(tmplName, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFile(filepath.Join(dir, dest), rendered, force); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf(" wrote %s\n", dest)
|
||||
}
|
||||
|
||||
for tmplName, dest := range docs {
|
||||
full := filepath.Join(dir, dest)
|
||||
if fileExists(full) {
|
||||
fmt.Printf(" kept %s (already exists)\n", dest)
|
||||
continue
|
||||
}
|
||||
b, err := templatesFS.ReadFile(tmplName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFile(full, b, true); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf(" wrote %s\n", dest)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyState reports what's wrong with dir's loop setup: warnings are
|
||||
// non-fatal, missing are required files that aren't present.
|
||||
func verifyState(dir string) (warnings, missing []string) {
|
||||
for _, dest := range workflows {
|
||||
if !fileExists(filepath.Join(dir, dest)) {
|
||||
missing = append(missing, dest)
|
||||
}
|
||||
}
|
||||
for _, dest := range docs {
|
||||
if !fileExists(filepath.Join(dir, dest)) {
|
||||
missing = append(missing, dest)
|
||||
}
|
||||
}
|
||||
// The loop is only as good as its gate: warn if there's no non-loop
|
||||
// workflow to serve as CI.
|
||||
if !hasCIWorkflow(dir) {
|
||||
warnings = append(warnings, "no non-loop workflow found in .github/workflows — the loop needs a CI gate (build/test/lint) to merge safely")
|
||||
}
|
||||
return warnings, missing
|
||||
}
|
||||
|
||||
func runVerify(c *cli.Context) error {
|
||||
dir := c.String("dir")
|
||||
warnings, missing := verifyState(dir)
|
||||
|
||||
for _, m := range missing {
|
||||
fmt.Printf(" MISSING %s\n", m)
|
||||
}
|
||||
for _, w := range warnings {
|
||||
fmt.Printf(" WARN %s\n", w)
|
||||
}
|
||||
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("loop is not fully scaffolded (%d file(s) missing) — run `micro loop init`", len(missing))
|
||||
}
|
||||
|
||||
fmt.Println(" OK loop workflows and queue are present")
|
||||
fmt.Println()
|
||||
fmt.Println("Reminders the CLI can't check:")
|
||||
fmt.Println(" • The token secret must be set in the repo (Settings → Secrets).")
|
||||
fmt.Println(" • Branch protection must require the CI checks with 0 approvals,")
|
||||
fmt.Println(" so the builder's auto-merge can land PRs on green CI.")
|
||||
if len(warnings) > 0 {
|
||||
return fmt.Errorf("%d warning(s) — see above", len(warnings))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func render(tmplName string, cfg config) ([]byte, error) {
|
||||
b, err := templatesFS.ReadFile(tmplName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Custom delimiters so GitHub Actions' own ${{ }} expressions pass through
|
||||
// untouched — only << >> placeholders are substituted.
|
||||
t, err := template.New(filepath.Base(tmplName)).Delims("<<", ">>").Option("missingkey=error").Parse(string(b))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", tmplName, err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := t.Execute(&buf, cfg); err != nil {
|
||||
return nil, fmt.Errorf("render %s: %w", tmplName, err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func writeFile(path string, content []byte, force bool) error {
|
||||
if fileExists(path) && !force {
|
||||
return fmt.Errorf("%s already exists (use --force to overwrite)", path)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, content, 0o644)
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
// hasCIWorkflow reports whether .github/workflows holds any workflow that is
|
||||
// not one of the loop's own (i.e. a plausible CI gate).
|
||||
func hasCIWorkflow(dir string) bool {
|
||||
entries, err := os.ReadDir(filepath.Join(dir, ".github", "workflows"))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if strings.HasPrefix(name, "loop-") {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// detectDefaultBranch best-effort resolves the repo's default branch, falling
|
||||
// back to "main".
|
||||
func detectDefaultBranch(dir string) string {
|
||||
out, err := exec.Command("git", "-C", dir, "symbolic-ref", "--short", "refs/remotes/origin/HEAD").Output()
|
||||
if err == nil {
|
||||
ref := strings.TrimSpace(string(out))
|
||||
if i := strings.LastIndex(ref, "/"); i >= 0 {
|
||||
ref = ref[i+1:]
|
||||
}
|
||||
if ref != "" {
|
||||
return ref
|
||||
}
|
||||
}
|
||||
return "main"
|
||||
}
|
||||
|
||||
func printNextSteps(cfg config) {
|
||||
fmt.Printf(`
|
||||
Loop scaffolded. Next steps (the CLI can't do these for you):
|
||||
|
||||
1. Edit .github/loop/NORTH_STAR.md — the direction the loop aligns to.
|
||||
Seed .github/loop/PRIORITIES.md with a few real items.
|
||||
|
||||
2. Add a repo secret named %s: a fine-grained user PAT (contents + pull
|
||||
requests + issues write) for an account the agent (%s) responds to.
|
||||
The workflows no-op until this secret exists.
|
||||
|
||||
3. Ensure a CI workflow named %q exists and that branch protection on %q
|
||||
requires its checks with 0 approving reviews — that green-CI gate is
|
||||
what lets the builder auto-merge safely.
|
||||
|
||||
4. Commit these files, then trigger a run:
|
||||
Actions → "Loop: Planner" / "Loop: Builder" → Run workflow.
|
||||
|
||||
Verify anytime with: micro loop verify
|
||||
`, cfg.TokenSecret, cfg.AgentMention, cfg.CIWorkflow, cfg.DefaultBranch)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderProducesValidPlaceholderFreeYAML(t *testing.T) {
|
||||
cfg := config{
|
||||
DefaultBranch: "main",
|
||||
AgentMention: "@codex",
|
||||
TokenSecret: "LOOP_TOKEN",
|
||||
CIWorkflow: "CI",
|
||||
PlannerCron: "0 * * * *",
|
||||
BuilderCron: "30 * * * *",
|
||||
}
|
||||
|
||||
for tmplName := range workflows {
|
||||
rendered, err := render(tmplName, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("render %s: %v", tmplName, err)
|
||||
}
|
||||
s := string(rendered)
|
||||
|
||||
// No unresolved substitution delimiters should remain...
|
||||
if strings.Contains(s, "<<") || strings.Contains(s, ">>") {
|
||||
t.Errorf("%s still contains << >> placeholders after render", tmplName)
|
||||
}
|
||||
// ...but GitHub Actions' own ${{ }} expressions must survive verbatim.
|
||||
if !strings.Contains(s, "${{ secrets.LOOP_TOKEN") {
|
||||
t.Errorf("%s lost its ${{ secrets.LOOP_TOKEN }} expression", tmplName)
|
||||
}
|
||||
// The configured values must be substituted in.
|
||||
if !strings.Contains(s, "@codex") {
|
||||
t.Errorf("%s missing agent mention", tmplName)
|
||||
}
|
||||
// Structural sanity: a workflow needs these top-level keys.
|
||||
for _, key := range []string{"name:", "on:", "jobs:"} {
|
||||
if !strings.Contains(s, key) {
|
||||
t.Errorf("%s missing top-level %q", tmplName, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitThenVerify(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// A non-loop workflow so verify's CI-gate check passes.
|
||||
mustWrite(t, filepath.Join(dir, ".github/workflows/ci.yml"), "name: CI\n")
|
||||
|
||||
if err := scaffold(dir, config{
|
||||
DefaultBranch: "main",
|
||||
AgentMention: "@codex",
|
||||
TokenSecret: "LOOP_TOKEN",
|
||||
CIWorkflow: "CI",
|
||||
PlannerCron: "0 * * * *",
|
||||
BuilderCron: "30 * * * *",
|
||||
}, false); err != nil {
|
||||
t.Fatalf("scaffold: %v", err)
|
||||
}
|
||||
|
||||
for _, dest := range workflows {
|
||||
if !fileExists(filepath.Join(dir, dest)) {
|
||||
t.Errorf("expected %s to be written", dest)
|
||||
}
|
||||
}
|
||||
for _, dest := range docs {
|
||||
if !fileExists(filepath.Join(dir, dest)) {
|
||||
t.Errorf("expected %s to be written", dest)
|
||||
}
|
||||
}
|
||||
|
||||
// A second scaffold without --force must fail on an existing workflow.
|
||||
if err := scaffold(dir, config{DefaultBranch: "main", AgentMention: "@codex", TokenSecret: "LOOP_TOKEN", CIWorkflow: "CI", PlannerCron: "0 * * * *", BuilderCron: "30 * * * *"}, false); err == nil {
|
||||
t.Error("expected second scaffold without --force to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyMissingFilesFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if _, missing := verifyState(dir); len(missing) == 0 {
|
||||
t.Error("expected missing files in an empty dir")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyWarnsWithoutCIGate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := scaffold(dir, config{DefaultBranch: "main", AgentMention: "@codex", TokenSecret: "LOOP_TOKEN", CIWorkflow: "CI", PlannerCron: "0 * * * *", BuilderCron: "30 * * * *"}, false); err != nil {
|
||||
t.Fatalf("scaffold: %v", err)
|
||||
}
|
||||
// Only loop-* workflows exist → no CI gate.
|
||||
if hasCIWorkflow(dir) {
|
||||
t.Error("expected no CI gate when only loop-* workflows are present")
|
||||
}
|
||||
}
|
||||
|
||||
func mustWrite(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# North Star
|
||||
|
||||
> **Edit this file.** It is the single source of direction the loop aligns every
|
||||
> increment to. The planner ranks work against it; the builder builds toward it.
|
||||
> Be concrete — vague direction produces vague increments.
|
||||
|
||||
## Mission
|
||||
|
||||
<One or two sentences: the problem this repository solves and who it's for.>
|
||||
|
||||
## Right now
|
||||
|
||||
<The current priority — what "better" means this month. The planner weights the
|
||||
queue toward this.>
|
||||
|
||||
## Guardrails
|
||||
|
||||
- One concern per PR; small and reversible.
|
||||
- The gate is green CI, not a human review — keep the test/lint suite strong,
|
||||
because the loop is only as good as its evaluator.
|
||||
- **Off-limits without a human** (surface as notes, never auto-merge): breaking
|
||||
public API changes, brand/positioning/marketing copy, new dependencies,
|
||||
architectural rewrites, product-default changes with broad behavioral impact.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Priorities
|
||||
|
||||
A single ranked queue, highest-value first. Each item links a scoped issue the
|
||||
loop can build and CI can verify. The **planner** keeps this current; the
|
||||
**builder** takes the top item whose issue is still open.
|
||||
|
||||
<!--
|
||||
Seed this with a few real items to give the loop a running start, e.g.:
|
||||
|
||||
1. Add retry with backoff to the HTTP client — #123
|
||||
2. Document the config file format — #124
|
||||
3. Fix flaky timeout in the cache tests — #125
|
||||
|
||||
The planner will re-rank, drop completed items, and file issues for new gaps.
|
||||
Reorder or edit this file at any time to redirect the loop.
|
||||
-->
|
||||
@@ -0,0 +1,46 @@
|
||||
name: "Loop: Builder"
|
||||
|
||||
# Generated by `micro loop init`. The GENERATOR of the loop: each run it opens a
|
||||
# fresh tracking issue and dispatches the agent to build the top open item from
|
||||
# .github/loop/PRIORITIES.md as a single-concern PR, then enables native
|
||||
# auto-merge so the PR lands once the required CI checks pass. Branch protection
|
||||
# (required checks, 0 approvals) is the gate — there is no merge sweep.
|
||||
#
|
||||
# A FRESH issue per run is deliberate: agents derive the PR branch name from the
|
||||
# triggering issue, so reusing one tracker collapses every run onto one branch
|
||||
# and only the first PR opens. Gated on << .TokenSecret >> (see loop-planner.yml).
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
schedule:
|
||||
- cron: "<< .BuilderCron >>"
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: loop-builder
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Open an increment issue and dispatch the agent
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.<< .TokenSecret >> || github.token }}
|
||||
HAS_TOKEN: ${{ secrets.<< .TokenSecret >> != '' }}
|
||||
REPO: ${{ github.repository }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
if [ "$HAS_TOKEN" != "true" ]; then
|
||||
echo "<< .TokenSecret >> is not set — skipping (see loop-planner.yml)."
|
||||
exit 0
|
||||
fi
|
||||
ISSUE_URL=$(gh issue create --repo "$REPO" \
|
||||
--title "Loop: build increment #$RUN_NUMBER" \
|
||||
--body "Autonomous build increment. Direction: .github/loop/NORTH_STAR.md. Queue: .github/loop/PRIORITIES.md.")
|
||||
ISSUE_NUM="${ISSUE_URL##*/}"
|
||||
echo "Opened issue #$ISSUE_NUM — dispatching the builder."
|
||||
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body \
|
||||
"<< .AgentMention >> Build one increment for this repository, aligned to .github/loop/NORTH_STAR.md. PICK THE WORK: take the highest-ranked item in .github/loop/PRIORITIES.md whose linked issue is still OPEN — that is your task and its issue is the one you close. If the queue is empty or every item's issue is closed, pick the single highest-value improvement yourself. Implement it, then VERIFY the project builds, tests, and lints (use the commands documented in the README or the CI workflow). Open the PR YOURSELF from the shell — do NOT use a make_pr tool (it may be a no-op stub): \`git switch -c loop/increment-$ISSUE_NUM\`, \`git push -u origin loop/increment-$ISSUE_NUM\`, \`gh pr create --base << .DefaultBranch >> --title \"<title>\" --body \"<body; include 'Closes #<the item's issue>' so it leaves the queue, and 'Closes #$ISSUE_NUM' for this tracker>\"\`, then \`gh pr merge --squash --auto --delete-branch\` so it lands on green CI. One concern per PR; stay out of breaking public API and brand/positioning copy."
|
||||
@@ -0,0 +1,48 @@
|
||||
name: "Loop: Planner"
|
||||
|
||||
# Generated by `micro loop init`. Part of an autonomous improvement loop:
|
||||
# a PLANNER (this file) keeps a ranked queue, a BUILDER builds the top item,
|
||||
# and CI + a TRIAGE pass are the evaluator. The loop dispatches a coding agent
|
||||
# by @mention on a fresh tracking issue each run.
|
||||
#
|
||||
# Gated on the << .TokenSecret >> secret: the agent ignores @mentions from the
|
||||
# github-actions bot, so the dispatch must post as a real user (a PAT). Until
|
||||
# that secret is set the workflow runs but no-ops. Direction lives in
|
||||
# .github/loop/NORTH_STAR.md; the ranked queue in .github/loop/PRIORITIES.md.
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
schedule:
|
||||
- cron: "<< .PlannerCron >>"
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: loop-planner
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Open a planning issue and dispatch the agent
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.<< .TokenSecret >> || github.token }}
|
||||
HAS_TOKEN: ${{ secrets.<< .TokenSecret >> != '' }}
|
||||
REPO: ${{ github.repository }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
if [ "$HAS_TOKEN" != "true" ]; then
|
||||
echo "<< .TokenSecret >> is not set — skipping."
|
||||
echo "The agent ignores @mentions from the github-actions bot, so a"
|
||||
echo "user PAT is required. Add a << .TokenSecret >> secret to activate."
|
||||
exit 0
|
||||
fi
|
||||
ISSUE_URL=$(gh issue create --repo "$REPO" \
|
||||
--title "Loop: planning review #$RUN_NUMBER" \
|
||||
--body "Autonomous planning pass. Direction: .github/loop/NORTH_STAR.md. Queue: .github/loop/PRIORITIES.md.")
|
||||
ISSUE_NUM="${ISSUE_URL##*/}"
|
||||
echo "Opened issue #$ISSUE_NUM — dispatching the planner."
|
||||
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body \
|
||||
"<< .AgentMention >> Act as the planner for this repository. (1) Read .github/loop/NORTH_STAR.md for direction, then assess current state — recently merged PRs and open issues — so the queue reflects reality (drop done items, don't re-queue in-flight work). (2) Maintain a SINGLE ranked queue in .github/loop/PRIORITIES.md, highest-value first, each item linking a scoped, CI-verifiable issue (#N). For any prioritized gap with no issue, file one: \`gh issue create --title \"<scoped task>\" --body \"<goal, scope, acceptance criteria>\"\`. (3) If the ranking actually changed, open ONE PR for PRIORITIES.md: \`git switch -c loop/planner-$ISSUE_NUM\`, \`git push -u origin loop/planner-$ISSUE_NUM\`, \`gh pr create --base << .DefaultBranch >> --title \"<title>\" --body \"<summary, Closes #$ISSUE_NUM>\"\`, then \`gh pr merge --squash --auto --delete-branch\`. If the queue is already accurate, just close this issue (\`gh issue close $ISSUE_NUM\`). Do NOT make breaking or architectural changes yourself — surface those as notes for a human. Open the PR yourself from the shell with gh; do not use a make_pr tool."
|
||||
@@ -0,0 +1,46 @@
|
||||
name: "Loop: Triage"
|
||||
|
||||
# Generated by `micro loop init`. The feedback path of the evaluator: when the
|
||||
# CI workflow ("<< .CIWorkflow >>") fails on a non-PR run, this dispatches the
|
||||
# agent to root-cause the failure and file scoped fix issues back into the
|
||||
# planner's queue — so failures become fixes with no human in the middle, short
|
||||
# of a decision that is genuinely a human's. Gated on << .TokenSecret >>.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["<< .CIWorkflow >>"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: loop-triage
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
# Only real failures on branch pushes/schedules — not PR-run failures, which
|
||||
# the PR author already sees.
|
||||
if: ${{ github.event.workflow_run.conclusion == 'failure' && github.event.workflow_run.event != 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: File a triage issue and dispatch the agent
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.<< .TokenSecret >> || github.token }}
|
||||
HAS_TOKEN: ${{ secrets.<< .TokenSecret >> != '' }}
|
||||
REPO: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
RUN_URL: ${{ github.event.workflow_run.html_url }}
|
||||
run: |
|
||||
if [ "$HAS_TOKEN" != "true" ]; then
|
||||
echo "<< .TokenSecret >> is not set — skipping (see loop-planner.yml)."
|
||||
exit 0
|
||||
fi
|
||||
ISSUE_URL=$(gh issue create --repo "$REPO" \
|
||||
--title "Loop: triage failed run $RUN_ID" \
|
||||
--body "The '<< .CIWorkflow >>' workflow failed: $RUN_URL")
|
||||
ISSUE_NUM="${ISSUE_URL##*/}"
|
||||
echo "Opened issue #$ISSUE_NUM — dispatching triage."
|
||||
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body \
|
||||
"<< .AgentMention >> Triage the failed CI run at $RUN_URL. Read the logs and root-cause each distinct failure. DEDUPE against open issues — if a failure matches an existing issue, comment 'recurred' there instead of filing a duplicate. For each genuine, self-contained defect, file a scoped issue (\`gh issue create --title \"<scoped fix>\" --body \"<root cause, where, acceptance>\"\`) so the planner/builder can pick it up and the next CI run verifies it. IGNORE transient flakes (network blips, provider outages, timeouts with no code cause). Anything needing a breaking or architectural change: label it needs-human and describe it — do NOT auto-file it as a routine fix. Close this issue (\`gh issue close $ISSUE_NUM\`) when triage is done."
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
_ "go-micro.dev/v6/cmd/micro/cli/deploy"
|
||||
_ "go-micro.dev/v6/cmd/micro/flow"
|
||||
_ "go-micro.dev/v6/cmd/micro/inspect"
|
||||
_ "go-micro.dev/v6/cmd/micro/loop"
|
||||
_ "go-micro.dev/v6/cmd/micro/mcp"
|
||||
_ "go-micro.dev/v6/cmd/micro/resource"
|
||||
_ "go-micro.dev/v6/cmd/micro/run"
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
// Agent Ollama — a self-contained agent powered by Ollama Cloud.
|
||||
//
|
||||
// This example demonstrates the full harness loop — service tools, custom
|
||||
// tools, agent memory, guardrails, and streaming — using the Ollama
|
||||
// provider with gpt-oss:120b on Ollama Cloud.
|
||||
//
|
||||
// It creates a "knowledge" service with two endpoints (Add, Search) that
|
||||
// the agent discovers as tools, plus a custom "current_time" tool. The
|
||||
// agent answers natural-language questions by calling those tools.
|
||||
//
|
||||
// Run (Ollama Cloud — default):
|
||||
//
|
||||
// OLLAMA_API_KEY=your-key go run main.go
|
||||
//
|
||||
// Run (local Ollama):
|
||||
//
|
||||
// OLLAMA_BASE_URL=http://localhost:11434 \
|
||||
// OLLAMA_MODEL=llama3.2 \
|
||||
// go run main.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6"
|
||||
"go-micro.dev/v6/agent"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// knowledge service — a tiny in-memory knowledge base
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type KnowledgeEntry struct {
|
||||
ID string `json:"id" description:"Unique entry identifier"`
|
||||
Topic string `json:"topic" description:"Topic or category"`
|
||||
Content string `json:"content" description:"The knowledge content"`
|
||||
}
|
||||
|
||||
type AddKnowledgeRequest struct {
|
||||
Topic string `json:"topic" description:"Topic or category (required)"`
|
||||
Content string `json:"content" description:"The knowledge content (required)"`
|
||||
}
|
||||
|
||||
type AddKnowledgeResponse struct {
|
||||
Entry *KnowledgeEntry `json:"entry" description:"The added entry"`
|
||||
}
|
||||
|
||||
type SearchKnowledgeRequest struct {
|
||||
Topic string `json:"topic,omitempty" description:"Filter by topic (optional)"`
|
||||
Keyword string `json:"keyword,omitempty" description:"Search keyword in content (optional)"`
|
||||
}
|
||||
|
||||
type SearchKnowledgeResponse struct {
|
||||
Entries []*KnowledgeEntry `json:"entries" description:"Matching entries"`
|
||||
}
|
||||
|
||||
type KnowledgeService struct {
|
||||
mu sync.RWMutex
|
||||
entries []*KnowledgeEntry
|
||||
nextID int
|
||||
}
|
||||
|
||||
// Add stores a new knowledge entry.
|
||||
//
|
||||
// @example {"topic": "go", "content": "Go interfaces are implicit."}
|
||||
func (s *KnowledgeService) Add(ctx context.Context, req *AddKnowledgeRequest, rsp *AddKnowledgeResponse) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.nextID++
|
||||
e := &KnowledgeEntry{
|
||||
ID: fmt.Sprintf("kb-%d", s.nextID),
|
||||
Topic: req.Topic,
|
||||
Content: req.Content,
|
||||
}
|
||||
s.entries = append(s.entries, e)
|
||||
rsp.Entry = e
|
||||
return nil
|
||||
}
|
||||
|
||||
// Search finds knowledge entries by topic or keyword.
|
||||
//
|
||||
// @example {"topic": "go"}
|
||||
// @example {"keyword": "interface"}
|
||||
func (s *KnowledgeService) Search(ctx context.Context, req *SearchKnowledgeRequest, rsp *SearchKnowledgeResponse) error {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, e := range s.entries {
|
||||
if req.Topic != "" && !strings.EqualFold(e.Topic, req.Topic) {
|
||||
continue
|
||||
}
|
||||
if req.Keyword != "" && !strings.Contains(strings.ToLower(e.Content), strings.ToLower(req.Keyword)) {
|
||||
continue
|
||||
}
|
||||
rsp.Entries = append(rsp.Entries, e)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func main() {
|
||||
// Ollama Cloud is the default. Override with env vars for local Ollama.
|
||||
baseURL := os.Getenv("OLLAMA_BASE_URL")
|
||||
if baseURL == "" {
|
||||
baseURL = "https://ollama.com/v1"
|
||||
}
|
||||
model := os.Getenv("OLLAMA_MODEL")
|
||||
if model == "" {
|
||||
model = "gpt-oss:120b"
|
||||
}
|
||||
apiKey := os.Getenv("OLLAMA_API_KEY")
|
||||
|
||||
fmt.Println("╔══════════════════════════════════════════╗")
|
||||
fmt.Println("║ Ollama-Powered Go Micro Agent ║")
|
||||
fmt.Println("╚══════════════════════════════════════════╝")
|
||||
fmt.Println()
|
||||
fmt.Printf(" Ollama URL: %s\n", baseURL)
|
||||
fmt.Printf(" Model: %s\n", model)
|
||||
if apiKey != "" {
|
||||
fmt.Printf(" API Key: (set)\n")
|
||||
} else {
|
||||
fmt.Printf(" API Key: (none — set OLLAMA_API_KEY)\n")
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// 1. Start the knowledge service. Its handlers become agent tools.
|
||||
svc := micro.NewService("knowledge")
|
||||
svc.Handle(new(KnowledgeService))
|
||||
go svc.Run()
|
||||
|
||||
// Give the service a moment to register.
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// 2. Create the agent. It discovers the knowledge service endpoints
|
||||
// as tools automatically, plus gets a custom "current_time" tool.
|
||||
ag := micro.NewAgent("ollama-assistant",
|
||||
micro.AgentServices("knowledge"),
|
||||
micro.AgentPrompt(
|
||||
"You are a helpful knowledge assistant. You can search and add to "+
|
||||
"a knowledge base using the knowledge service tools. "+
|
||||
"When asked about the current time, use the current_time tool. "+
|
||||
"Be concise and factual.",
|
||||
),
|
||||
micro.AgentProvider("ollama"),
|
||||
micro.AgentModel(model),
|
||||
micro.AgentAPIKey(apiKey),
|
||||
micro.AgentBaseURL(baseURL),
|
||||
micro.AgentMaxSteps(10),
|
||||
micro.AgentLoopLimit(3),
|
||||
// Custom tool — any function, not tied to a service.
|
||||
agent.WithTool(
|
||||
"current_time",
|
||||
"Get the current date and time in a human-readable format",
|
||||
map[string]any{
|
||||
"timezone": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional timezone (defaults to local)",
|
||||
},
|
||||
},
|
||||
func(ctx context.Context, input map[string]any) (string, error) {
|
||||
tz, _ := input["timezone"].(string)
|
||||
if tz == "" {
|
||||
return time.Now().Format("2006-01-02 15:04:05 MST"), nil
|
||||
}
|
||||
loc, err := time.LoadLocation(tz)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unknown timezone: %s", tz)
|
||||
}
|
||||
return time.Now().In(loc).Format("2006-01-02 15:04:05 MST"), nil
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
// 3. Seed initial knowledge via the agent's first question.
|
||||
questions := []string{
|
||||
"What time is it now?",
|
||||
"Add a new knowledge entry: topic 'go', content 'Go interfaces are implicit — a type implements an interface by having the required methods.'",
|
||||
"Add another entry: topic 'go', content 'Go is a statically typed, compiled language designed at Google.'",
|
||||
"Add another entry: topic 'ai', content 'Large language models generate text by predicting the next token in a sequence.'",
|
||||
"Search the knowledge base for entries about Go.",
|
||||
"Search for everything in the knowledge base.",
|
||||
}
|
||||
|
||||
fmt.Println("─── Agent Demo ───")
|
||||
fmt.Println()
|
||||
|
||||
for i, q := range questions {
|
||||
fmt.Printf("Q%d: %s\n", i+1, q)
|
||||
fmt.Print("A: ")
|
||||
|
||||
resp, err := ag.Ask(context.Background(), q)
|
||||
if err != nil {
|
||||
fmt.Printf("error: %v\n", err)
|
||||
fmt.Println()
|
||||
continue
|
||||
}
|
||||
|
||||
// Show tool calls the agent made.
|
||||
if len(resp.ToolCalls) > 0 {
|
||||
for _, tc := range resp.ToolCalls {
|
||||
args, _ := json.Marshal(tc.Input)
|
||||
fmt.Printf(" [tool] %s(%s)\n", tc.Name, string(args))
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println(resp.Reply)
|
||||
if resp.Reply == "" && len(resp.ToolCalls) == 0 {
|
||||
fmt.Println("(no response)")
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// 4. Streaming demonstration.
|
||||
fmt.Println("─── Streaming Demo ───")
|
||||
fmt.Println()
|
||||
streamQ := "Explain what Go Micro is in two sentences."
|
||||
fmt.Printf("Q: %s\n", streamQ)
|
||||
fmt.Print("A: ")
|
||||
|
||||
stream, err := ag.Stream(context.Background(), streamQ)
|
||||
if err != nil {
|
||||
fmt.Printf("stream error: %v\n", err)
|
||||
} else {
|
||||
for {
|
||||
chunk, err := stream.Recv()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if chunk.Reply != "" {
|
||||
fmt.Print(chunk.Reply)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("Done.")
|
||||
}
|
||||
@@ -21,11 +21,11 @@ changes, architectural rewrites. Those go to the human.
|
||||
|
||||
## Work queue (ranked)
|
||||
|
||||
1. **Add first-agent preflight diagnostics** ([#3604](https://github.com/micro/go-micro/issues/3604)) — the 0→hero docs handoff shipped, so the next adoption bottleneck is install/runtime confidence before a new user tries the first provider-backed agent. Keep this first because the current strategic goal is developer adoption: one no-secret diagnostic should tell a developer whether Go, the `micro` binary/runtime, provider-key setup, and local ports are ready before the first-agent walkthrough.
|
||||
2. **Finalize the universe notify step after an agent-backed timeout** ([#3589](https://github.com/micro/go-micro/issues/3589)) — recent live-provider CI exposed a real services → agents → workflows seam: an agent-backed notify side effect can complete while the client observes a timeout, leaving pending flow state and late duplicate notifications. Keep this high because it protects the 0→hero/reliability contract, but leave the adoption preflight first so the queue does not drift back to only internal hardening.
|
||||
3. **Prevent duplicate tool side effects in the plan/delegate harness** ([#3559](https://github.com/micro/go-micro/issues/3559)) — correctness still matters where it protects real user trust. Plan/delegate is central to the services → agents lifecycle, and duplicate side effects undermine the “agent as dependable service” story.
|
||||
4. **Expose `fallback_echo` during A2A streaming fallback conformance** ([#3560](https://github.com/micro/go-micro/issues/3560)) — keep interop conformance trustworthy without letting it dominate the adoption queue. This is scoped, testable, and protects the A2A promise developers see in the README and site.
|
||||
5. **Propagate agent run cancellation and deadlines through model and tool calls** ([#3544](https://github.com/micro/go-micro/issues/3544)) — after the on-ramp items, the highest-value remaining Now-phase resilience gap is predictable failure semantics across agent runs, model calls, tool calls, plan/delegate, and flow handoffs. Tool retries and live-provider deadline tuning are in place; the lifecycle still needs cancellation/deadline propagation so work fails safely instead of becoming opaque loops.
|
||||
1. **Make the first-agent on-ramp discoverable from README and website navigation** ([#3640](https://github.com/micro/go-micro/issues/3640)) — the install and first-run smoke contract shipped in #3635, but adoption still depends on a new developer finding the next step without already knowing the guide names. Put the walkable path from install/scaffold to first agent, chat/inspect, and 0→hero in the README/site wayfinding so the verified harness becomes lived DX rather than hidden depth.
|
||||
2. **Make plan/delegate live-provider conformance avoid duplicate task side effects** ([#3626](https://github.com/micro/go-micro/issues/3626)) — atlascloud still has an open Now-phase trust gap where plan/delegate created duplicate launch tasks and missed the delegated notification. This remains the highest open runtime fix because plan/delegate is the bridge from agent reasoning to service side effects; developers cannot trust the services → agents lifecycle if a model can replay tool calls and leave handoff pending.
|
||||
3. **Make universe checkout conformance send exactly one concierge notification** ([#3633](https://github.com/micro/go-micro/issues/3633)) — the newest live-provider scan found the durable checkout/universe path resuming correctly but notifying the buyer twice. Rank it next because durable workflows are the 0→hero proof, and resume idempotency must be boring before deeper observability or future interop work matters.
|
||||
4. **Expose `fallback_echo` during A2A streaming fallback conformance** ([#3560](https://github.com/micro/go-micro/issues/3560)) — this remains the next scoped Now-phase interop/conformance gap: it protects the A2A streaming promise developers see in the README and site by ensuring the non-native streaming fallback path still receives the tool surface, without letting protocol depth outrank the on-ramp or side-effect safety.
|
||||
5. **Propagate agent run cancellation and deadlines through model and tool calls** ([#3544](https://github.com/micro/go-micro/issues/3544)) — the highest-value remaining Now-phase resilience gap after the live-provider side-effect fixes is predictable failure semantics across agent runs, model calls, tool calls, plan/delegate, and flow handoffs. Tool retries and live-provider deadline tuning are in place; the lifecycle still needs cancellation/deadline propagation so work fails safely instead of becoming opaque loops.
|
||||
6. **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.
|
||||
7. **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.
|
||||
|
||||
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env bash
|
||||
# Smoke-test the documented install.sh path without network access.
|
||||
# It builds the local CLI, packages it like a release archive, installs it into a
|
||||
# temporary bin directory through internal/scripts/install.sh, then checks the
|
||||
# first-run command boundaries shown in the getting-started docs.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)
|
||||
TMP_DIR=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
|
||||
ARCHIVE_DIR="$TMP_DIR/archive"
|
||||
INSTALL_DIR="$TMP_DIR/install"
|
||||
ARCHIVE="$TMP_DIR/micro-local.tar.gz"
|
||||
mkdir -p "$ARCHIVE_DIR" "$INSTALL_DIR"
|
||||
|
||||
CGO_ENABLED=0 go build -o "$ARCHIVE_DIR/micro" ./cmd/micro
|
||||
chmod +x "$ARCHIVE_DIR/micro"
|
||||
tar -C "$ARCHIVE_DIR" -czf "$ARCHIVE" micro
|
||||
|
||||
MICRO_INSTALL_DIR="$INSTALL_DIR" \
|
||||
MICRO_INSTALL_ARCHIVE="$ARCHIVE" \
|
||||
MICRO_VERSION="local-smoke" \
|
||||
PATH="$INSTALL_DIR:$PATH" \
|
||||
"$ROOT/internal/scripts/install.sh" > "$TMP_DIR/install.out"
|
||||
|
||||
MICRO="$INSTALL_DIR/micro"
|
||||
if [[ ! -x "$MICRO" ]]; then
|
||||
echo "installed micro binary not found at $MICRO" >&2
|
||||
cat "$TMP_DIR/install.out" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
require_output() {
|
||||
local description=$1
|
||||
local expected=$2
|
||||
shift 2
|
||||
local output
|
||||
if ! output=$("$MICRO" "$@" 2>&1); then
|
||||
echo "micro $* failed while checking $description" >&2
|
||||
echo "$output" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$output" != *"$expected"* ]]; then
|
||||
echo "micro $* missing expected text '$expected' for $description" >&2
|
||||
echo "$output" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
require_output "version" "micro version" --version
|
||||
require_output "root help" "COMMANDS" --help
|
||||
require_output "service scaffold" "micro new" new --help
|
||||
require_output "first-agent preflight" "preflight" agent preflight --help
|
||||
require_output "local runtime" "micro run" run --help
|
||||
require_output "agent chat" "micro chat" chat --help
|
||||
require_output "agent inspection" "micro inspect agent" inspect agent --help
|
||||
require_output "flow inspection" "micro inspect flow" inspect flow --help
|
||||
|
||||
echo "✓ install smoke path verified"
|
||||
@@ -243,6 +243,8 @@ func runPlanDelegate(provider string) error {
|
||||
cl := harnessutil.Client(provider, reg)
|
||||
mem := store.NewMemoryStore()
|
||||
liveAgentOpts := harnessutil.AgentOptions(provider)
|
||||
commsCheckpoint := flow.StoreCheckpoint(mem, "agent-comms")
|
||||
conductorCheckpoint := flow.StoreCheckpoint(mem, "agent-conductor")
|
||||
|
||||
// Real services.
|
||||
taskSvc := new(TaskService)
|
||||
@@ -267,6 +269,7 @@ func runPlanDelegate(provider string) error {
|
||||
agent.Prompt("You handle outbound notifications. Use the notify service."),
|
||||
agent.Provider(provider), agent.APIKey(apiKey),
|
||||
agent.WithRegistry(reg), agent.WithClient(cl), agent.WithStore(mem),
|
||||
agent.WithCheckpoint(commsCheckpoint),
|
||||
}
|
||||
commsOpts = append(commsOpts, liveAgentOpts...)
|
||||
comms := agent.New(commsOpts...)
|
||||
@@ -281,6 +284,7 @@ func runPlanDelegate(provider string) error {
|
||||
agent.Prompt("You coordinate launch work. Plan first, create tasks, and delegate notifications to the \"comms\" agent."),
|
||||
agent.Provider(provider), agent.APIKey(apiKey),
|
||||
agent.WithRegistry(reg), agent.WithClient(cl), agent.WithStore(mem),
|
||||
agent.WithCheckpoint(conductorCheckpoint),
|
||||
}
|
||||
conductorOpts = append(conductorOpts, liveAgentOpts...)
|
||||
conductor := agent.New(conductorOpts...)
|
||||
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@@ -107,17 +108,63 @@ type SendResponse struct {
|
||||
Sent bool `json:"sent"`
|
||||
}
|
||||
|
||||
type Notify struct{ sent int64 }
|
||||
type Notify struct {
|
||||
mu sync.Mutex
|
||||
sent int64
|
||||
seen map[string]struct{}
|
||||
}
|
||||
|
||||
// Send delivers a notification.
|
||||
// @example {"to": "buyer@acme.com", "message": "Your order is confirmed"}
|
||||
func (s *Notify) Send(_ context.Context, req *SendRequest, rsp *SendResponse) error {
|
||||
key := req.To + "\x00" + req.Message
|
||||
s.mu.Lock()
|
||||
if s.seen == nil {
|
||||
s.seen = make(map[string]struct{})
|
||||
}
|
||||
if _, ok := s.seen[key]; ok {
|
||||
s.mu.Unlock()
|
||||
fmt.Printf(" \033[35m[notify]\033[0m 📨 duplicate suppressed to=%s %q\n", req.To, req.Message)
|
||||
rsp.Sent = true
|
||||
return nil
|
||||
}
|
||||
s.seen[key] = struct{}{}
|
||||
s.mu.Unlock()
|
||||
|
||||
atomic.AddInt64(&s.sent, 1)
|
||||
fmt.Printf(" \033[35m[notify]\033[0m 📨 to=%s %q\n", req.To, req.Message)
|
||||
rsp.Sent = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func dispatchNotifyStep(agentName string, ntf *Notify) flow.StepFunc {
|
||||
dispatch := flow.Dispatch(agentName)
|
||||
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
|
||||
}
|
||||
return completeNotifyOnObservedSideEffect(ctx, in, ntf, before, 2*time.Second, err)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
if atomic.LoadInt64(&ntf.sent) > before {
|
||||
in.Data = []byte("Buyer notified.")
|
||||
return in, nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return in, dispatchErr
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
return in, dispatchErr
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mock LLM — the only fake. The concierge agent uses it to decide to notify.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -275,7 +322,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: flow.Dispatch("concierge")},
|
||||
flow.Step{Name: "notify", Run: dispatchNotifyStep("concierge", ntf)},
|
||||
),
|
||||
)
|
||||
if err := checkout.Register(reg, br, cl); err != nil {
|
||||
@@ -331,7 +378,7 @@ func runUniverse(provider string) int {
|
||||
check(atomic.LoadInt64(&inv.reserves) == 1, "inventory still reserved exactly once (completed step not replayed)")
|
||||
check(atomic.LoadInt64(&pay.attempts) == 2, "payment attempted twice (failed once, then charged)")
|
||||
check(atomic.LoadInt64(&ord.confirms) == 1, "order confirmed after resume")
|
||||
check(atomic.LoadInt64(&ntf.sent) >= 1, "buyer notified by the concierge agent")
|
||||
check(atomic.LoadInt64(&ntf.sent) == 1, "buyer notified exactly once by the concierge agent")
|
||||
check(atomic.LoadInt64(&wrapped) >= 1, "agent tool-execution wrapper observed the call")
|
||||
|
||||
if pend, _ := checkout.Pending(ctx); true {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/flow"
|
||||
)
|
||||
|
||||
// TestUniverseHarnessContract makes the 0→hero harness part of the ordinary
|
||||
@@ -18,3 +24,47 @@ func TestUniverseHarnessContract(t *testing.T) {
|
||||
t.Fatalf("universe harness exited with code %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyStepCompletesAfterObservedSideEffectTimeout(t *testing.T) {
|
||||
ntf := new(Notify)
|
||||
before := atomic.LoadInt64(&ntf.sent)
|
||||
go func() {
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
var rsp SendResponse
|
||||
if err := ntf.Send(context.Background(), &SendRequest{
|
||||
To: "buyer@acme.com",
|
||||
Message: "Your order is confirmed.",
|
||||
}, &rsp); err != nil {
|
||||
t.Errorf("send notification: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
out, err := completeNotifyOnObservedSideEffect(
|
||||
context.Background(),
|
||||
flow.State{Data: []byte(`{"order":"order-1"}`)},
|
||||
ntf,
|
||||
before,
|
||||
time.Second,
|
||||
errors.New("client observed timeout"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("notify completion returned error: %v", err)
|
||||
}
|
||||
if got := out.String(); got != "Buyer notified." {
|
||||
t.Fatalf("result = %q, want Buyer notified.", got)
|
||||
}
|
||||
if got := atomic.LoadInt64(&ntf.sent); got != 1 {
|
||||
t.Fatalf("notifications sent = %d, want 1", got)
|
||||
}
|
||||
|
||||
var rsp SendResponse
|
||||
if err := ntf.Send(context.Background(), &SendRequest{
|
||||
To: "buyer@acme.com",
|
||||
Message: "Your order is confirmed.",
|
||||
}, &rsp); err != nil {
|
||||
t.Fatalf("duplicate send: %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt64(&ntf.sent); got != 1 {
|
||||
t.Fatalf("notifications sent after duplicate = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,17 @@ This directory owns the no-secret reference scenario for the Go Micro
|
||||
services → agents → workflows lifecycle. It is intentionally small and
|
||||
scripted so CI can run it on every push without external services or model keys.
|
||||
|
||||
`run.sh` verifies four boundaries together:
|
||||
`run.sh` verifies five boundaries together:
|
||||
|
||||
1. **Run** — `micro run` remains available as the local development entry point.
|
||||
2. **Chat** — `micro chat` remains available as the interactive agent entry point.
|
||||
3. **Inspect** — `micro inspect agent <name>` and `micro inspect flow <name>`
|
||||
1. **First agent** — `micro new`, `micro agent preflight`, `micro run`,
|
||||
`micro chat`, and `micro inspect agent <name>` remain available as the
|
||||
documented first-agent walkthrough path.
|
||||
2. **Run** — `micro run` remains available as the local development entry point.
|
||||
3. **Chat** — `micro chat` remains available as the interactive agent entry point.
|
||||
4. **Inspect** — `micro inspect agent <name>` and `micro inspect flow <name>`
|
||||
remain available as the local run-history inspection step, with `micro flow
|
||||
runs` preserving durable workflow history inspection.
|
||||
4. **Deploy** — `micro deploy --dry-run <target>` remains available as the
|
||||
5. **Deploy** — `micro deploy --dry-run <target>` remains available as the
|
||||
deployment-boundary checkpoint. The dry run resolves configured deploy targets
|
||||
and services and prints the remote build/copy/systemd/health plan without
|
||||
building binaries, opening SSH connections, running `rsync`, or touching
|
||||
@@ -24,15 +27,17 @@ and A2A with only the LLM mocked.
|
||||
## Local and CI entry points
|
||||
|
||||
The default GitHub harness workflow runs this script on every push and pull
|
||||
request after the 0→1 scaffold contract. Developers can run the same no-secret
|
||||
contract locally with:
|
||||
request after the install smoke check and 0→1 scaffold contract. Developers can
|
||||
verify the installer seam alone with `make install-smoke`, or run the same
|
||||
no-secret contract locally with:
|
||||
|
||||
```sh
|
||||
make harness
|
||||
```
|
||||
|
||||
That target intentionally exercises both 0→1 scaffold variants, the 0→hero
|
||||
scenario, the event-driven agent-flow harness, and mock provider conformance, so
|
||||
That target intentionally exercises the install script smoke path, both 0→1
|
||||
scaffold variants, the 0→hero scenario, the event-driven agent-flow harness, and
|
||||
mock provider conformance, so
|
||||
the public scaffold → run/chat → inspect → deploy lifecycle stays executable
|
||||
outside CI as well. Live provider checks remain separate and gated by configured
|
||||
API keys (`make provider-conformance` or the scheduled/manual CI job).
|
||||
|
||||
@@ -14,8 +14,10 @@ func TestZeroToHeroReferenceDocs(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"make harness",
|
||||
"go test ./cmd/micro/cli/new -run TestZeroToOne -count=1",
|
||||
"go test ./cmd/micro -run TestFirstAgentWalkthroughCLIBoundaries -count=1",
|
||||
"go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1",
|
||||
"go test ./cmd/micro/cli/deploy -run TestDeployDryRun -count=1",
|
||||
"go test ./examples/support -run 'TestRunSupportMockSmoke|TestZeroToHeroReadmeDocumentsLifecycle' -count=1",
|
||||
"./internal/harness/zero-to-hero-ci/run.sh",
|
||||
"go run ./internal/harness/agent-flow",
|
||||
"make provider-conformance-mock",
|
||||
|
||||
@@ -6,9 +6,12 @@ cd "$ROOT"
|
||||
|
||||
# Keep the developer inner-loop boundaries executable and discoverable in CI
|
||||
# without secrets or long-running daemons.
|
||||
go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1
|
||||
go test ./cmd/micro -run 'TestFirstAgentWalkthroughCLIBoundaries|TestZeroToHeroCLIBoundaries' -count=1
|
||||
go test ./cmd/micro/cli/deploy -run TestDeployDryRun -count=1
|
||||
|
||||
# Deterministic no-secret reference scenarios. These use the real Go Micro
|
||||
# runtime and mock only the LLM provider.
|
||||
# runtime and mock only the LLM provider. The support example is the maintained
|
||||
# runnable 0→hero app; keep it in this CI path so its documented run/chat/inspect
|
||||
# journey cannot drift from the framework.
|
||||
go test ./examples/support -run 'TestRunSupportMockSmoke|TestZeroToHeroReadmeDocumentsLifecycle' -count=1
|
||||
go test ./internal/harness/universe ./internal/harness/plan-delegate -run 'Test.*Harness|TestPlanDelegateEndToEnd|TestPlanDelegateFlowHandoff' -count=1
|
||||
|
||||
@@ -23,8 +23,12 @@ case $OS in
|
||||
*) echo "Unsupported OS: $OS"; exit 1 ;;
|
||||
esac
|
||||
|
||||
# Determine install directory
|
||||
if [ "$EUID" -eq 0 ] || [ "$(id -u)" -eq 0 ]; then
|
||||
# Determine install directory. MICRO_INSTALL_DIR is intended for CI/local smoke
|
||||
# tests that verify the installer without writing to a real system directory.
|
||||
if [ -n "${MICRO_INSTALL_DIR:-}" ]; then
|
||||
INSTALL_DIR="$MICRO_INSTALL_DIR"
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
elif [ "$EUID" -eq 0 ] || [ "$(id -u)" -eq 0 ]; then
|
||||
INSTALL_DIR="/usr/local/bin"
|
||||
else
|
||||
INSTALL_DIR="$HOME/.local/bin"
|
||||
@@ -44,8 +48,11 @@ fi
|
||||
TMP_DIR=$(mktemp -d)
|
||||
TMP_FILE="${TMP_DIR}/micro.tar.gz"
|
||||
|
||||
# Download
|
||||
if command -v curl &> /dev/null; then
|
||||
# Download, or use a local release archive supplied by the deterministic smoke
|
||||
# harness. The default path still fetches the documented GitHub release artifact.
|
||||
if [ -n "${MICRO_INSTALL_ARCHIVE:-}" ]; then
|
||||
cp "$MICRO_INSTALL_ARCHIVE" "$TMP_FILE"
|
||||
elif command -v curl &> /dev/null; then
|
||||
curl -fsSL "$URL" -o "$TMP_FILE"
|
||||
elif command -v wget &> /dev/null; then
|
||||
wget -q "$URL" -O "$TMP_FILE"
|
||||
|
||||
@@ -5,6 +5,10 @@ core:
|
||||
url: /docs/getting-started.html
|
||||
- title: AI Integration
|
||||
url: /docs/ai-integration.html
|
||||
- title: Your First Agent
|
||||
url: /docs/guides/your-first-agent.html
|
||||
- title: 0→hero Reference
|
||||
url: /docs/guides/zero-to-hero.html
|
||||
- title: MCP & AI Agents
|
||||
url: /docs/mcp.html
|
||||
- title: Deployment
|
||||
@@ -32,12 +36,8 @@ examples:
|
||||
- title: Real-World Examples
|
||||
url: /docs/examples/realworld/
|
||||
guides:
|
||||
- title: Your First Agent
|
||||
url: /docs/guides/your-first-agent.html
|
||||
- title: Debugging your agent
|
||||
url: /docs/guides/debugging-agents.html
|
||||
- title: 0→hero Reference
|
||||
url: /docs/guides/zero-to-hero.html
|
||||
- title: Plan & Delegate
|
||||
url: /docs/guides/plan-delegate.html
|
||||
- title: Agent Guardrails
|
||||
@@ -78,6 +78,7 @@ project:
|
||||
- title: Server (optional)
|
||||
url: /docs/server.html
|
||||
search_order:
|
||||
- /docs/guides/your-first-agent.html
|
||||
- /docs/guides/zero-to-hero.html
|
||||
- /docs/guides/debugging-agents.html
|
||||
- /docs/getting-started.html
|
||||
|
||||
@@ -43,6 +43,7 @@ The built-in providers currently register these capability interfaces:
|
||||
| `gemini` | Yes | No | No | No |
|
||||
| `groq` | Yes | No | No | Yes |
|
||||
| `mistral` | Yes | No | No | Yes |
|
||||
| `ollama` | Yes | No | No | Yes |
|
||||
| `openai` | Yes | Yes | No | Yes |
|
||||
| `together` | Yes | No | No | Yes |
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
_ "go-micro.dev/v6/ai/gemini"
|
||||
_ "go-micro.dev/v6/ai/groq"
|
||||
_ "go-micro.dev/v6/ai/mistral"
|
||||
_ "go-micro.dev/v6/ai/ollama"
|
||||
_ "go-micro.dev/v6/ai/openai"
|
||||
_ "go-micro.dev/v6/ai/together"
|
||||
)
|
||||
|
||||
@@ -47,7 +47,7 @@ export ANTHROPIC_API_KEY=sk-ant-...
|
||||
Plain service calls work without a model key; the key is only needed when the
|
||||
agent reasons over tools.
|
||||
|
||||
Run the read-only first-agent preflight before starting the walkthrough:
|
||||
Run the read-only first-agent preflight before starting the walkthrough. The same CLI boundary is covered by CI with `go test ./cmd/micro -run TestFirstAgentWalkthroughCLIBoundaries -count=1`, so the documented scaffold → run → chat → inspect path stays visible in the local harness:
|
||||
|
||||
```sh
|
||||
micro agent preflight
|
||||
|
||||
@@ -18,11 +18,13 @@ cloud credentials?"
|
||||
| Boundary | Contract | CI check |
|
||||
| --- | --- | --- |
|
||||
| Scaffold | `micro new` generates a runnable service with and without MCP support. | `go test ./cmd/micro/cli/new -run TestZeroToOne -count=1` |
|
||||
| First agent | `micro new`, `micro agent preflight`, `micro run`, `micro chat`, and `micro inspect agent` stay available for the documented first-agent walkthrough. | `go test ./cmd/micro -run TestFirstAgentWalkthroughCLIBoundaries -count=1` |
|
||||
| Run | `micro run` remains the local development entry point. | `go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1` |
|
||||
| Chat | `micro chat` remains the interactive agent entry point. | `go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1` |
|
||||
| Inspect | `micro inspect agent`, `micro inspect flow`, and `micro flow runs` remain discoverable for run history. | `go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1` |
|
||||
| Deploy | `micro deploy --dry-run` resolves deploy targets without touching remote infrastructure. | `go test ./cmd/micro/cli/deploy -run TestDeployDryRun -count=1` |
|
||||
| Runtime | Real services, agents, durable flows, store-backed history, delegation, and A2A run with only the model mocked. | `./internal/harness/zero-to-hero-ci/run.sh` and `make provider-conformance-mock` |
|
||||
| Runtime reference app | `examples/support` runs typed services, an agent using those services as tools, an event-driven flow handoff, and an approval gate with only the model mocked. | `go test ./examples/support -run 'TestRunSupportMockSmoke|TestZeroToHeroReadmeDocumentsLifecycle' -count=1` |
|
||||
| Runtime harnesses | Real services, agents, durable flows, store-backed history, delegation, and A2A run with only the model mocked. | `./internal/harness/zero-to-hero-ci/run.sh` and `make provider-conformance-mock` |
|
||||
|
||||
## Run the runnable example
|
||||
|
||||
@@ -52,13 +54,22 @@ SSH access, or remote service is required.
|
||||
Use the smaller checks when you are working on one seam:
|
||||
|
||||
```sh
|
||||
# Install script and first-run CLI boundary, with no network or provider keys.
|
||||
make install-smoke
|
||||
|
||||
# Scaffold → run/call contract.
|
||||
go test ./cmd/micro/cli/new -run TestZeroToOne -count=1
|
||||
|
||||
# First-agent walkthrough boundary: scaffold, preflight, run, chat, inspect.
|
||||
go test ./cmd/micro -run TestFirstAgentWalkthroughCLIBoundaries -count=1
|
||||
|
||||
# CLI inner-loop commands: run, chat, inspect, flow runs, deploy --dry-run.
|
||||
go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1
|
||||
go test ./cmd/micro/cli/deploy -run TestDeployDryRun -count=1
|
||||
|
||||
# Maintained 0→hero support-desk reference app.
|
||||
go test ./examples/support -run 'TestRunSupportMockSmoke|TestZeroToHeroReadmeDocumentsLifecycle' -count=1
|
||||
|
||||
# Durable services → agents → workflows reference scenarios.
|
||||
./internal/harness/zero-to-hero-ci/run.sh
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ It's built on a pluggable architecture of Go interfaces: service discovery, clie
|
||||
|
||||
## Learn More
|
||||
|
||||
Start with [Getting Started](getting-started.html) for install and the first local service. If you want the full services → agents → workflows on-ramp in one walkable sequence, use the [0→hero reference path](guides/zero-to-hero.html): it links the exact scaffold, run, chat, inspect, and deploy dry-run commands covered by CI.
|
||||
Start with [Getting Started](getting-started.html) for install and the first local service. Then follow the first-agent on-ramp: [Your First Agent](guides/your-first-agent.html) to build and chat with a service-backed agent, [Debugging your agent](guides/debugging-agents.html) to inspect runs and memory, and the [0→hero reference path](guides/zero-to-hero.html) to walk the full scaffold → run → chat → inspect → deploy dry-run lifecycle covered by CI.
|
||||
|
||||
Otherwise continue to read the docs for more information about the framework.
|
||||
|
||||
|
||||
@@ -23,8 +23,12 @@ case $OS in
|
||||
*) echo "Unsupported OS: $OS"; exit 1 ;;
|
||||
esac
|
||||
|
||||
# Determine install directory
|
||||
if [ "$EUID" -eq 0 ] || [ "$(id -u)" -eq 0 ]; then
|
||||
# Determine install directory. MICRO_INSTALL_DIR is intended for CI/local smoke
|
||||
# tests that verify the installer without writing to a real system directory.
|
||||
if [ -n "${MICRO_INSTALL_DIR:-}" ]; then
|
||||
INSTALL_DIR="$MICRO_INSTALL_DIR"
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
elif [ "$EUID" -eq 0 ] || [ "$(id -u)" -eq 0 ]; then
|
||||
INSTALL_DIR="/usr/local/bin"
|
||||
else
|
||||
INSTALL_DIR="$HOME/.local/bin"
|
||||
@@ -44,8 +48,11 @@ fi
|
||||
TMP_DIR=$(mktemp -d)
|
||||
TMP_FILE="${TMP_DIR}/micro.tar.gz"
|
||||
|
||||
# Download
|
||||
if command -v curl &> /dev/null; then
|
||||
# Download, or use a local release archive supplied by the deterministic smoke
|
||||
# harness. The default path still fetches the documented GitHub release artifact.
|
||||
if [ -n "${MICRO_INSTALL_ARCHIVE:-}" ]; then
|
||||
cp "$MICRO_INSTALL_ARCHIVE" "$TMP_FILE"
|
||||
elif command -v curl &> /dev/null; then
|
||||
curl -fsSL "$URL" -o "$TMP_FILE"
|
||||
elif command -v wget &> /dev/null; then
|
||||
wget -q "$URL" -O "$TMP_FILE"
|
||||
|
||||
@@ -100,6 +100,10 @@ func AgentModel(m string) AgentOption { return agent.Model(m) }
|
||||
// AgentAPIKey sets the API key for the LLM provider.
|
||||
func AgentAPIKey(k string) AgentOption { return agent.APIKey(k) }
|
||||
|
||||
// AgentBaseURL sets the base URL for the LLM provider. Use this to point
|
||||
// the provider at a non-default endpoint (e.g., local Ollama, a proxy).
|
||||
func AgentBaseURL(url string) AgentOption { return agent.BaseURL(url) }
|
||||
|
||||
// ApproveFunc gates an agent's tool calls before they run.
|
||||
type ApproveFunc = agent.ApproveFunc
|
||||
|
||||
|
||||
Reference in New Issue
Block a user