Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@ A personal home server. News, mail, search, weather, markets, video — the ever
| `client/telegram/` | Telegram bot with commands and groups |
| `client/whatsapp/` | WhatsApp Business API integration |
| `service/wallet/` | Credit system, Stripe, x402 |
| `service/search/` | Brave web search, readability reader |
| `service/search/` | Brave provider, readability reader, the /search page (no service of its own) |
| `service/db/` | Per-user records for services and apps (headless) |
| `service/web/` | Fetch a URL and return readable content (headless) |
| `service/web/` | The open web: search it (`web.Search`), fetch a URL (`web.Fetch`) |
| `service/index/` | Search across the caller's own content (headless) |
| `service/stream/` | The console — this instance's own event timeline |
| `service/chat/` | Live discussion rooms attached to an item |
Expand All @@ -62,6 +62,11 @@ go vet ./... # vet
is the runtime core that hosts them, not a service itself. See
`docs/SERVICE_REGISTRY.md` for what is registered, which are headless, which
are account-scoped, and which are deliberately not exposed to the agent
- A service is named for a **domain** (a noun), never an action. Tool names are
derived as `service_method`, so an action-named service leaves its main method
nothing to be called but the same word — that is how `search.Search` produced
the tool name `search_search`. Methods returning the current set of something
are all called `List`. Enforced by `TestNoMethodRepeatsItsService`

## The go-micro relationship

Expand Down
7 changes: 3 additions & 4 deletions agent/guest.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,9 @@ var guestAllowedTools = map[string]bool{
"weather_forecast": true,
"video": true,
"video_search": true,
"web_search": true, // legacy alias of search_web
"search_web": true,
"web_fetch": true, // legacy alias of search_fetch
"search_fetch": true,
"web_search": true,
"search_web": true, // legacy name for web_search
"web_fetch": true,
"social": true,
"social_search": true,
"blog_list": true,
Expand Down
16 changes: 9 additions & 7 deletions agent/native.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,15 +95,14 @@ var agentToolLabels = map[string]string{
"social": "Social",
"video": "Video",
"blog": "Blog",
"search": "Search",
"web": "Search",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Migrate legacy search-only agent scopes

On upgrade, a user-defined agent persisted in user_agents.json with only the previously exposed search service retains Tools: ["search"], because there is no migration to web. Since filterServices returns every service when a nonempty allow-list matches nothing, that agent changes from search-only to access to all private services, including mail, images, events, db, and wallet, potentially exposing personal data or enabling charged actions. Translate the legacy service ID before applying the allow-list.

Useful? React with 👍 / 👎.

"places": "Places",
"index": "Index",
"apps": "Apps",
"mail": "Mail",
"images": "Images",
"islam": "Islam",
"events": "Events",
"web": "Web",
"chat": "Chat",
"stream": "Stream",
"db": "Storage",
Expand Down Expand Up @@ -458,12 +457,15 @@ func nativeToolFormatterName(name string) string {
switch method {
case "search":
return "news_search"
case "headlines":
return "news_headlines"
case "list":
return "news_list"
default:
return "news"
}
case "search":
case "web":
if method == "fetch" {
return "web_fetch"
}
return "web_search"
}
}
Expand Down Expand Up @@ -492,7 +494,7 @@ func nativeToolTitle(name string) string {
return "video"
case "blog":
return "blog"
case "search":
case "web":
return "search"
case "recall", "index":
return "memory"
Expand Down Expand Up @@ -531,7 +533,7 @@ func nativeToolLabel(name string) (label string, show bool) {
return "📺 Finding videos", true
case "blog":
return "📝 Reading the blog", true
case "search":
case "web":
return "🔎 Searching the web", true
case "recall", "index":
return "🧠 Recalling your data", true
Expand Down
25 changes: 20 additions & 5 deletions docs/SERVICE_REGISTRY.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,28 @@ Services live under `service/<name>/`. `internal/service` is the runtime core
that hosts them — it is not itself a service.

The exception is a **headless** service: a capability with no page, so no route
and no nav entry. `index`, `web` and `db` are headless — they exist for the
and no nav entry. `index` and `db` are headless — they exist for the
agent, for apps and for other services to call.

Two footnotes. `wallet` has a page that predates its service; both are the same
capability with two surfaces. And `/web` still 301s to `/search` for old links,
from when fetching lived inside search — the `web` service itself has no page.
capability with two surfaces. And the `web` service is reached at `/search`,
because "Search" is what a person looks for in the sidebar while `web` is what
the capability is about. The nav label is for humans; the service name is for
callers. `/web` still 301s to `/search` for old links.

**A service is named for a domain, not for an action.** Every one is a noun —
`news`, `mail`, `places`, `web` — and its methods say what to do with that
domain. This is not style: tool names are derived as `service_method`, so a
service named for an action leaves its main method nothing to be called but the
same word. `search` used to be a service, its one method had to be `Search`, and
the derived tool name was `search_search`. Web search is now `web.Search`
alongside `web.Fetch`, matching the `/web/fetch` and `/web/read` routes.
`TestNoMethodRepeatsItsService` holds the line.

Methods that return the current set of something are all called `List` —
`news.List`, `blog.List`, `social.List`, `video.List`, `markets.List`,
`stream.List`, `events.List`, `db.List` — so the derived names are uniform and
guessable.

## What is registered

Expand All @@ -45,13 +61,12 @@ from when fetching lived inside search — the `web` service itself has no page.
| `markets` | /markets | ✅ | | Crypto, futures, commodities, currencies |
| `news` | /news | ✅ | | RSS aggregation, sentiment, search |
| `places` | /places | ✅ | | Maps and points of interest |
| `search` | /search | ✅ | | Web search |
| `social` | /social | ✅ | | Threads, replies, status |
| `stream` | /stream | ✅ | | The console: this instance's own timeline |
| `video` | /video | ✅ | | Search and playback |
| `wallet` | /wallet | ✅ | ✅ | Credit check, charge, balance |
| `weather` | /weather | ✅ | | Forecast and pollen |
| `web` | | ✅ | | Fetch a URL, return readable content |
| `web` | /search | ✅ | | Search the web; fetch a URL and return readable content |

## Account-scoped

Expand Down
68 changes: 68 additions & 0 deletions internal/service/naming_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package service

import (
"path/filepath"
"strings"
"testing"
)

// A tool's name is derived, not written: service + "_" + method. That only
// reads well if the two halves say different things, which holds when a
// service is named for a domain (news, mail, places) and a method for what it
// does with that domain.
//
// A service named for an action has nowhere left to go: its main method has to
// repeat it. That is how "search" ended up with search.Search, deriving the
// tool name search_search. The capability moved to web.Search, and this test
// stops the next one arriving.
func TestNoMethodRepeatsItsService(t *testing.T) {
forEachService(t, func(svc string, methods []string) {
for _, m := range methods {
if strings.EqualFold(m, svc) {
t.Errorf("%s.%s derives the tool name %s_%s — name the service for a "+
"domain and the method for what it does",
svc, m, svc, strings.ToLower(m))
}
}
})
}

// Two endpoints deriving the same tool name would make one of them
// unreachable, silently.
func TestDerivedToolNamesAreUnique(t *testing.T) {
seen := map[string]string{}
forEachService(t, func(svc string, methods []string) {
for _, m := range methods {
name := svc + "_" + strings.ToLower(m)
if prev, dup := seen[name]; dup {
t.Errorf("%s.%s and %s both derive %q", svc, m, prev, name)
continue
}
seen[name] = svc + "." + m
}
})
}

// forEachService walks the service packages, handing each one its name and the
// RPC methods it declares. The directory name is the service name — that is
// the convention, and SERVICE_REGISTRY.md documents it.
func forEachService(t *testing.T, fn func(service string, methods []string)) {
t.Helper()
root := repoRoot(t)
dirs, err := filepath.Glob(filepath.Join(root, "service", "*"))
if err != nil {
t.Fatalf("glob: %v", err)
}
checked := 0
for _, dir := range dirs {
methods, _, ok := scanService(t, dir)
if !ok {
continue // a package with no handler, e.g. search
}
checked++
fn(filepath.Base(dir), methods)
}
if checked < 15 {
t.Fatalf("only scanned %d services; the scan is not finding them", checked)
}
}
36 changes: 18 additions & 18 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -578,9 +578,9 @@ func main() {
},
Handle: func(args map[string]any) (string, error) {
q, _ := args["q"].(string)
var rsp search.SearchResponse
if err := service.Call(context.Background(), "search", "Server.Search",
&search.SearchRequest{Query: q}, &rsp); err != nil {
var rsp web.SearchResponse
if err := service.Call(context.Background(), "web", "Server.Search",
&web.SearchRequest{Query: q}, &rsp); err != nil {
return "", err
}
return rsp.Text, nil
Expand Down Expand Up @@ -618,9 +618,9 @@ func main() {
case string:
fmt.Sscanf(v, "%d", &limit)
}
var rsp news.HeadlinesResponse
if err := service.Call(context.Background(), "news", "Server.Headlines",
&news.HeadlinesRequest{Topic: topic, Limit: limit}, &rsp); err != nil {
var rsp news.ListResponse
if err := service.Call(context.Background(), "news", "Server.List",
&news.ListRequest{Topic: topic, Limit: limit}, &rsp); err != nil {
return "", err
}
return rsp.Text, nil
Expand Down Expand Up @@ -787,9 +787,9 @@ func main() {
},
Handle: func(args map[string]any) (string, error) {
category, _ := args["category"].(string)
var rsp markets.PricesResponse
if err := service.Call(context.Background(), "markets", "Server.Prices",
&markets.PricesRequest{Category: category}, &rsp); err != nil {
var rsp markets.ListResponse
if err := service.Call(context.Background(), "markets", "Server.List",
&markets.ListRequest{Category: category}, &rsp); err != nil {
return "", err
}
return rsp.Text, nil
Expand Down Expand Up @@ -976,9 +976,9 @@ func main() {
Aliases: []string{"social"},
Description: "Get the latest social posts from the network.",
Handle: func(args map[string]any) (string, error) {
var rsp social.FeedResponse
if err := service.Call(context.Background(), "social", "Server.Feed",
&social.FeedRequest{}, &rsp); err != nil {
var rsp social.ListResponse
if err := service.Call(context.Background(), "social", "Server.List",
&social.ListRequest{}, &rsp); err != nil {
return "", err
}
return rsp.Text, nil
Expand All @@ -991,9 +991,9 @@ func main() {
Aliases: []string{"video"},
Description: "Get the latest videos from curated channels.",
Handle: func(args map[string]any) (string, error) {
var rsp video.LatestResponse
if err := service.Call(context.Background(), "video", "Server.Latest",
&video.LatestRequest{}, &rsp); err != nil {
var rsp video.ListResponse
if err := service.Call(context.Background(), "video", "Server.List",
&video.ListRequest{}, &rsp); err != nil {
return "", err
}
return rsp.Text, nil
Expand All @@ -1005,9 +1005,9 @@ func main() {
Name: "blog_list",
Description: "Get recent blog posts (titles, snippets and ids; use blog_read for one in full).",
Handle: func(args map[string]any) (string, error) {
var rsp blog.RecentResponse
if err := service.Call(context.Background(), "blog", "Server.Recent",
&blog.RecentRequest{}, &rsp); err != nil {
var rsp blog.ListResponse
if err := service.Call(context.Background(), "blog", "Server.List",
&blog.ListRequest{}, &rsp); err != nil {
return "", err
}
return rsp.Text, nil
Expand Down
14 changes: 7 additions & 7 deletions service/blog/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,23 @@ import (
// Server is the go-micro service handler for blog.
type Server struct{}

// RecentRequest controls how many posts to return.
type RecentRequest struct {
// ListRequest controls how many posts to return.
type ListRequest struct {
Limit int `json:"limit" description:"Optional max number of posts (default all recent)"`
}

// RecentResponse is a model-ready list of recent posts.
type RecentResponse struct {
// ListResponse is a model-ready list of recent posts.
type ListResponse struct {
Text string `json:"text" description:"Recent blog posts: titles, snippets and ids"`
}

// Recent returns recent blog posts (titles, snippets and ids).
// List returns recent blog posts (titles, snippets and ids).
// @example {}
func (Server) Recent(_ context.Context, req *RecentRequest, rsp *RecentResponse) error {
func (Server) List(_ context.Context, req *ListRequest, rsp *ListResponse) error {
rsp.Text = RecentText(req.Limit)
return nil
}

var toolDocs = service.Docs{
"Recent": "Read recent blog posts — titles, snippets and ids",
"List": "Read recent blog posts — titles, snippets and ids",
}
14 changes: 7 additions & 7 deletions service/markets/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,24 @@ import (
// as RPC endpoints and, through the agent and gateways, as AI tools.
type Server struct{}

// PricesRequest selects a market category.
type PricesRequest struct {
// ListRequest selects a market category.
type ListRequest struct {
Category string `json:"category" description:"crypto, futures, commodities or currencies (default crypto)"`
}

// PricesResponse is a model-ready price summary.
type PricesResponse struct {
// ListResponse is a model-ready price summary.
type ListResponse struct {
Text string `json:"text" description:"Live prices for the requested category"`
}

// Prices returns live market prices for cryptocurrencies, futures, commodities
// List returns live market prices for cryptocurrencies, futures, commodities
// and currencies.
// @example {"category": "crypto"}
func (Server) Prices(_ context.Context, req *PricesRequest, rsp *PricesResponse) error {
func (Server) List(_ context.Context, req *ListRequest, rsp *ListResponse) error {
rsp.Text = MarketsText(req.Category)
return nil
}

var toolDocs = service.Docs{
"Prices": "Get live prices for cryptocurrencies, futures, commodities and currencies",
"List": "Get live prices for cryptocurrencies, futures, commodities and currencies",
}
6 changes: 3 additions & 3 deletions service/markets/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ func TestMarketsViaMesh(t *testing.T) {
if err := service.Register("markets", new(Server)); err != nil {
t.Fatalf("register: %v", err)
}
var rsp PricesResponse
if err := service.Call(context.Background(), "markets", "Server.Prices",
&PricesRequest{Category: "crypto"}, &rsp); err != nil {
var rsp ListResponse
if err := service.Call(context.Background(), "markets", "Server.List",
&ListRequest{Category: "crypto"}, &rsp); err != nil {
t.Fatalf("call: %v", err)
}
}
Loading
Loading