Skip to content

Commit e4d7801

Browse files
committed
feat(ai): add ai/tools helper and 'micro chat' interactive agent
Extract the registry-discovery + RPC-execution loop from the web agent playground into a reusable ai/tools package: - tools.New(reg) creates a Set bound to a registry - Set.Discover() walks the registry and returns []ai.Tool with LLM-safe (underscored) names, remembering the mapping back to the original dotted form - Set.Handler(client) returns an ai.ToolHandler that resolves the safe name and issues the RPC Add cmd/micro/chat — an interactive 'micro chat' REPL that uses ai/tools to let users talk to their services through any registered AI provider. Supports --prompt for single-shot use, auto-detects the provider from --base_url, and falls back to the provider's conventional env var (ANTHROPIC_API_KEY, etc). Update README with the new command and the programmatic example.
1 parent dc2e12c commit e4d7801

5 files changed

Lines changed: 555 additions & 0 deletions

File tree

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,34 @@ micro run
149149

150150
Use `micro mcp serve` for local AI tools like Claude Code, or connect any MCP-compatible agent to the HTTP endpoint.
151151

152+
### micro chat
153+
154+
For an interactive terminal session that lets you talk to your services through an LLM:
155+
156+
```bash
157+
ANTHROPIC_API_KEY=sk-ant-... micro chat --provider anthropic
158+
> list all users
159+
> create an order for product 42
160+
```
161+
162+
`micro chat` discovers every service in the registry, exposes each endpoint as a tool, and lets the model orchestrate calls. The same building blocks (`ai/tools`) work from your own services:
163+
164+
```go
165+
import "go-micro.dev/v5/ai/tools"
166+
167+
set := tools.New(service.Registry())
168+
discovered, _ := set.Discover()
169+
170+
m := ai.New("anthropic",
171+
ai.WithAPIKey(key),
172+
ai.WithToolHandler(set.Handler(service.Client())),
173+
)
174+
resp, _ := m.Generate(ctx, &ai.Request{
175+
Prompt: userInput,
176+
Tools: discovered,
177+
})
178+
```
179+
152180
See the [MCP guide](https://go-micro.dev/docs/mcp.html) for authentication, scopes, and advanced usage.
153181

154182
## Multi-Service Binaries

ai/tools/tools.go

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
// Package tools turns go-micro services into ai.Tool definitions and
2+
// provides an ai.ToolHandler that executes tool calls by issuing RPCs
3+
// to the corresponding service.
4+
//
5+
// This is the building block that lets any go-micro service reason
6+
// about and call other services through an LLM:
7+
//
8+
// m := ai.New("anthropic",
9+
// ai.WithAPIKey(key),
10+
// ai.WithToolHandler(tools.Handler(service.Client())),
11+
// )
12+
// resp, _ := m.Generate(ctx, &ai.Request{
13+
// Prompt: userInput,
14+
// Tools: tools.FromRegistry(service.Registry()),
15+
// })
16+
package tools
17+
18+
import (
19+
"context"
20+
"encoding/json"
21+
"fmt"
22+
"strings"
23+
"sync"
24+
25+
"go-micro.dev/v5/ai"
26+
"go-micro.dev/v5/client"
27+
codecBytes "go-micro.dev/v5/codec/bytes"
28+
"go-micro.dev/v5/registry"
29+
)
30+
31+
// nameMap holds the mapping between LLM-safe tool names (no dots) and the
32+
// original `service.Endpoint` names used by the registry/client. Many
33+
// providers reject dots in tool names, so we substitute underscores when
34+
// presenting the tool and restore the original when executing.
35+
type nameMap struct {
36+
mu sync.RWMutex
37+
m map[string]string
38+
}
39+
40+
func (n *nameMap) put(safe, original string) {
41+
n.mu.Lock()
42+
n.m[safe] = original
43+
n.mu.Unlock()
44+
}
45+
46+
func (n *nameMap) get(safe string) (string, bool) {
47+
n.mu.RLock()
48+
v, ok := n.m[safe]
49+
n.mu.RUnlock()
50+
return v, ok
51+
}
52+
53+
// Set is the shared discovery state between FromRegistry and Handler.
54+
// Use New, then Discover + Handler together when you want the handler
55+
// to recognise LLM-safe tool names that were emitted by Discover.
56+
//
57+
// FromRegistry+Handler are convenience wrappers that create their own
58+
// internal Set; Set is exposed for callers that want to control the
59+
// lifecycle (e.g. cache the tool list and reuse it across turns).
60+
type Set struct {
61+
registry registry.Registry
62+
names *nameMap
63+
}
64+
65+
// New creates an empty Set bound to the given registry. Call Discover
66+
// to populate it. The registry is only used by Discover; Handler does
67+
// not need it.
68+
func New(reg registry.Registry) *Set {
69+
return &Set{
70+
registry: reg,
71+
names: &nameMap{m: map[string]string{}},
72+
}
73+
}
74+
75+
// Discover walks the registry and returns one ai.Tool per service
76+
// endpoint. The returned tools have LLM-safe names (dots replaced with
77+
// underscores); the Set remembers the mapping so Handler can route
78+
// calls back to the right service.
79+
func (s *Set) Discover() ([]ai.Tool, error) {
80+
services, err := s.registry.ListServices()
81+
if err != nil {
82+
return nil, err
83+
}
84+
85+
var tools []ai.Tool
86+
for _, svc := range services {
87+
full, err := s.registry.GetService(svc.Name)
88+
if err != nil || len(full) == 0 {
89+
continue
90+
}
91+
for _, ep := range full[0].Endpoints {
92+
original := fmt.Sprintf("%s.%s", svc.Name, ep.Name)
93+
safe := strings.ReplaceAll(original, ".", "_")
94+
s.names.put(safe, original)
95+
96+
desc := fmt.Sprintf("Call %s on %s service", ep.Name, svc.Name)
97+
if ep.Metadata != nil {
98+
if d, ok := ep.Metadata["description"]; ok && d != "" {
99+
desc = d
100+
}
101+
}
102+
103+
props := map[string]any{}
104+
if ep.Request != nil {
105+
for _, field := range ep.Request.Values {
106+
props[field.Name] = map[string]any{
107+
"type": jsonType(field.Type),
108+
"description": fmt.Sprintf("%s (%s)", field.Name, field.Type),
109+
}
110+
}
111+
}
112+
113+
tools = append(tools, ai.Tool{
114+
Name: safe,
115+
OriginalName: original,
116+
Description: desc,
117+
Properties: props,
118+
})
119+
}
120+
}
121+
122+
return tools, nil
123+
}
124+
125+
// Handler returns an ai.ToolHandler that executes tool calls against
126+
// the given client. Tool names may be the LLM-safe form (with
127+
// underscores) emitted by Discover or the original dotted form; both
128+
// resolve to the same RPC.
129+
func (s *Set) Handler(c client.Client) ai.ToolHandler {
130+
if c == nil {
131+
c = client.DefaultClient
132+
}
133+
return func(name string, input map[string]any) (any, string) {
134+
if orig, ok := s.names.get(name); ok {
135+
name = orig
136+
}
137+
parts := strings.SplitN(name, ".", 2)
138+
if len(parts) != 2 {
139+
return errResult("invalid tool name: " + name)
140+
}
141+
142+
inputBytes, err := json.Marshal(input)
143+
if err != nil {
144+
return errResult("failed to marshal input: " + err.Error())
145+
}
146+
147+
req := c.NewRequest(parts[0], parts[1], &codecBytes.Frame{Data: inputBytes})
148+
var rsp codecBytes.Frame
149+
if err := c.Call(context.Background(), req, &rsp); err != nil {
150+
return errResult(err.Error())
151+
}
152+
153+
var result any
154+
if err := json.Unmarshal(rsp.Data, &result); err != nil {
155+
result = string(rsp.Data)
156+
}
157+
return result, string(rsp.Data)
158+
}
159+
}
160+
161+
// FromRegistry is a convenience that builds a one-shot Set, discovers
162+
// tools, and returns just the tool list. Use NewSet directly if you
163+
// need to also wire up Handler against the same name mapping.
164+
func FromRegistry(reg registry.Registry) ([]ai.Tool, error) {
165+
return New(reg).Discover()
166+
}
167+
168+
// Handler is a convenience that returns an ai.ToolHandler bound to the
169+
// given client. It only resolves dotted "service.Endpoint" names — it
170+
// has no awareness of any LLM-safe name mapping. For full round-tripping
171+
// of underscored names emitted by FromRegistry, construct a Set with
172+
// New and call Set.Handler.
173+
func Handler(c client.Client) ai.ToolHandler {
174+
return (&Set{names: &nameMap{m: map[string]string{}}}).Handler(c)
175+
}
176+
177+
func errResult(msg string) (any, string) {
178+
encoded, _ := json.Marshal(map[string]string{"error": msg})
179+
return map[string]string{"error": msg}, string(encoded)
180+
}
181+
182+
// jsonType maps Go types to JSON schema types. Anything that isn't a
183+
// recognised primitive becomes "object".
184+
func jsonType(goType string) string {
185+
switch goType {
186+
case "string":
187+
return "string"
188+
case "int", "int32", "int64", "uint", "uint32", "uint64":
189+
return "integer"
190+
case "float32", "float64":
191+
return "number"
192+
case "bool":
193+
return "boolean"
194+
default:
195+
return "object"
196+
}
197+
}

ai/tools/tools_test.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package tools
2+
3+
import (
4+
"testing"
5+
6+
"go-micro.dev/v5/registry"
7+
)
8+
9+
func TestJSONType(t *testing.T) {
10+
cases := map[string]string{
11+
"string": "string",
12+
"int": "integer",
13+
"int64": "integer",
14+
"float64": "number",
15+
"bool": "boolean",
16+
"User": "object",
17+
"": "object",
18+
}
19+
for in, want := range cases {
20+
if got := jsonType(in); got != want {
21+
t.Errorf("jsonType(%q) = %q, want %q", in, got, want)
22+
}
23+
}
24+
}
25+
26+
func TestFromRegistry_Empty(t *testing.T) {
27+
reg := registry.NewMemoryRegistry()
28+
tools, err := FromRegistry(reg)
29+
if err != nil {
30+
t.Fatalf("FromRegistry: %v", err)
31+
}
32+
if len(tools) != 0 {
33+
t.Errorf("expected 0 tools, got %d", len(tools))
34+
}
35+
}
36+
37+
func TestFromRegistry_DiscoversEndpoints(t *testing.T) {
38+
reg := registry.NewMemoryRegistry()
39+
svc := &registry.Service{
40+
Name: "users",
41+
Version: "1.0.0",
42+
Nodes: []*registry.Node{
43+
{Id: "users-1", Address: "127.0.0.1:9000"},
44+
},
45+
Endpoints: []*registry.Endpoint{
46+
{
47+
Name: "Users.Get",
48+
Metadata: map[string]string{
49+
"description": "Fetch a user by ID",
50+
},
51+
Request: &registry.Value{
52+
Name: "GetRequest",
53+
Type: "GetRequest",
54+
Values: []*registry.Value{
55+
{Name: "id", Type: "string"},
56+
{Name: "expand", Type: "bool"},
57+
},
58+
},
59+
},
60+
},
61+
}
62+
if err := reg.Register(svc); err != nil {
63+
t.Fatalf("Register: %v", err)
64+
}
65+
66+
tools, err := FromRegistry(reg)
67+
if err != nil {
68+
t.Fatalf("FromRegistry: %v", err)
69+
}
70+
if len(tools) != 1 {
71+
t.Fatalf("expected 1 tool, got %d", len(tools))
72+
}
73+
74+
tool := tools[0]
75+
if tool.Name != "users_Users_Get" {
76+
t.Errorf("safe name = %q, want users_Users_Get", tool.Name)
77+
}
78+
if tool.OriginalName != "users.Users.Get" {
79+
t.Errorf("original = %q", tool.OriginalName)
80+
}
81+
if tool.Description != "Fetch a user by ID" {
82+
t.Errorf("description = %q", tool.Description)
83+
}
84+
85+
id, ok := tool.Properties["id"].(map[string]any)
86+
if !ok {
87+
t.Fatal("missing id property")
88+
}
89+
if id["type"] != "string" {
90+
t.Errorf("id type = %v", id["type"])
91+
}
92+
expand, ok := tool.Properties["expand"].(map[string]any)
93+
if !ok {
94+
t.Fatal("missing expand property")
95+
}
96+
if expand["type"] != "boolean" {
97+
t.Errorf("expand type = %v", expand["type"])
98+
}
99+
}
100+
101+
func TestSet_HandlerResolvesSafeName(t *testing.T) {
102+
s := New(registry.NewMemoryRegistry())
103+
s.names.put("users_Users_Get", "users.Users.Get")
104+
105+
resolved, ok := s.names.get("users_Users_Get")
106+
if !ok || resolved != "users.Users.Get" {
107+
t.Errorf("name map lookup = (%q, %v)", resolved, ok)
108+
}
109+
}
110+
111+
func TestSet_HandlerInvalidName(t *testing.T) {
112+
s := New(registry.NewMemoryRegistry())
113+
h := s.Handler(nil)
114+
115+
// "foo" has no dot, no mapping entry — should error cleanly.
116+
result, content := h("foo", map[string]any{})
117+
if result == nil {
118+
t.Fatal("expected error result")
119+
}
120+
if content == "" {
121+
t.Error("expected non-empty content")
122+
}
123+
}

0 commit comments

Comments
 (0)