Phase 1 delivers the complete durability and storage layer:
- WAL with crash recovery: Append-only journal with BLAKE3 checksums,
fsync guarantees, and proper seek-to-EOF on reopen
- Storage engine: sled-backed KVStore with scan_prefix for range queries
- Content-addressed storage: H:{hash}, V:{hash}, E:{hash} key patterns
- Ingestor: Background worker tailing WAL, writing to KV with 8-byte
aligned record headers for rkyv zero-copy deserialization
- Comprehensive tests: 31 tests covering crash recovery, round-trips,
and multi-cycle durability
New crates: stemedb-wal, stemedb-storage, stemedb-ingest
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
636 lines
16 KiB
Markdown
636 lines
16 KiB
Markdown
# Building an Internet-Connected Chatbot with ADK-Go
|
|
|
|
This guide shows how to build a chatbot that can search the web, fetch pages, and access external APIs to provide accurate, up-to-date information.
|
|
|
|
---
|
|
|
|
## Overview
|
|
|
|
The chatbot combines:
|
|
|
|
- **Google Search** for finding current information
|
|
- **URL Fetching** for reading full web pages
|
|
- **Custom API tools** for specific data sources
|
|
- **Session memory** for context across conversations
|
|
|
|
```
|
|
┌──────────────────────────────────────────────────────────────┐
|
|
│ Smart Chatbot │
|
|
├──────────────────────────────────────────────────────────────┤
|
|
│ Tools: │
|
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
|
│ │ Google │ │ Fetch │ │ Weather │ + more │
|
|
│ │ Search │ │ URL │ │ API │ │
|
|
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
|
├──────────────────────────────────────────────────────────────┤
|
|
│ Memory: User preferences, conversation history, facts │
|
|
└──────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## Project Setup
|
|
|
|
```bash
|
|
mkdir smart-chatbot && cd smart-chatbot
|
|
go mod init smart-chatbot
|
|
go get google.golang.org/adk
|
|
```
|
|
|
|
---
|
|
|
|
## Basic Internet-Connected Chatbot
|
|
|
|
```go
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"os"
|
|
|
|
"google.golang.org/adk/agent/llmagent"
|
|
"google.golang.org/adk/cmd/launcher/adk"
|
|
"google.golang.org/adk/cmd/launcher/full"
|
|
"google.golang.org/adk/model/gemini"
|
|
"google.golang.org/adk/server/restapi/services"
|
|
"google.golang.org/adk/tool"
|
|
"google.golang.org/adk/tool/geminitool"
|
|
"google.golang.org/genai"
|
|
)
|
|
|
|
func main() {
|
|
ctx := context.Background()
|
|
|
|
model, err := gemini.NewModel(ctx, "gemini-3-flash-preview", &genai.ClientConfig{
|
|
APIKey: os.Getenv("GOOGLE_API_KEY"),
|
|
})
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
chatbot, err := llmagent.New(llmagent.Config{
|
|
Name: "smart_assistant",
|
|
Model: model,
|
|
Description: "An intelligent assistant with internet access",
|
|
Instruction: `You are a helpful, knowledgeable assistant with access to the internet.
|
|
|
|
CAPABILITIES:
|
|
- Search the web for current information
|
|
- Look up facts, news, weather, sports scores, etc.
|
|
- Provide accurate, sourced information
|
|
|
|
GUIDELINES:
|
|
1. For factual questions, search to verify before answering
|
|
2. Always cite your sources when providing information from search
|
|
3. If search results are unclear or conflicting, say so
|
|
4. For opinions or analysis, clearly distinguish from facts
|
|
5. Be conversational but accurate
|
|
|
|
When you don't know something and can't find it, admit it honestly.`,
|
|
Tools: []tool.Tool{
|
|
geminitool.GoogleSearch{},
|
|
},
|
|
})
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
l := full.NewLauncher()
|
|
cfg := &adk.Config{AgentLoader: services.NewSingleAgentLoader(chatbot)}
|
|
if err := l.Execute(ctx, cfg, os.Args[1:]); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Adding Custom Tools for Rich Information
|
|
|
|
### URL Fetcher Tool
|
|
|
|
```go
|
|
import (
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"google.golang.org/adk/tool"
|
|
"google.golang.org/adk/tool/functiontool"
|
|
)
|
|
|
|
type FetchURLInput struct {
|
|
URL string `json:"url" jsonschema:"The URL to fetch content from"`
|
|
}
|
|
|
|
type FetchURLOutput struct {
|
|
Content string `json:"content"`
|
|
StatusCode int `json:"status_code"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
func fetchURL(ctx tool.Context, input FetchURLInput) FetchURLOutput {
|
|
client := &http.Client{Timeout: 15 * time.Second}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", input.URL, nil)
|
|
if err != nil {
|
|
return FetchURLOutput{Error: err.Error()}
|
|
}
|
|
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; SmartBot/1.0)")
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return FetchURLOutput{Error: err.Error()}
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Limit response size
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 100000))
|
|
if err != nil {
|
|
return FetchURLOutput{Error: err.Error()}
|
|
}
|
|
|
|
// Basic HTML to text (simplified)
|
|
content := stripHTML(string(body))
|
|
|
|
return FetchURLOutput{
|
|
Content: content,
|
|
StatusCode: resp.StatusCode,
|
|
}
|
|
}
|
|
|
|
func stripHTML(html string) string {
|
|
// Simple HTML tag removal (use a proper library in production)
|
|
var result strings.Builder
|
|
inTag := false
|
|
for _, r := range html {
|
|
switch {
|
|
case r == '<':
|
|
inTag = true
|
|
case r == '>':
|
|
inTag = false
|
|
case !inTag:
|
|
result.WriteRune(r)
|
|
}
|
|
}
|
|
return strings.TrimSpace(result.String())
|
|
}
|
|
```
|
|
|
|
### Weather API Tool
|
|
|
|
```go
|
|
type GetWeatherInput struct {
|
|
Location string `json:"location" jsonschema:"City name or zip code"`
|
|
Units string `json:"units,omitempty" jsonschema:"Units: metric or imperial (default: metric)"`
|
|
}
|
|
|
|
type GetWeatherOutput struct {
|
|
Location string `json:"location"`
|
|
Temperature float64 `json:"temperature"`
|
|
Conditions string `json:"conditions"`
|
|
Humidity int `json:"humidity"`
|
|
WindSpeed float64 `json:"wind_speed"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
func getWeather(ctx tool.Context, input GetWeatherInput) GetWeatherOutput {
|
|
apiKey := os.Getenv("OPENWEATHER_API_KEY")
|
|
if apiKey == "" {
|
|
return GetWeatherOutput{Error: "Weather API not configured"}
|
|
}
|
|
|
|
units := input.Units
|
|
if units == "" {
|
|
units = "metric"
|
|
}
|
|
|
|
url := fmt.Sprintf(
|
|
"https://api.openweathermap.org/data/2.5/weather?q=%s&units=%s&appid=%s",
|
|
url.QueryEscape(input.Location),
|
|
units,
|
|
apiKey,
|
|
)
|
|
|
|
resp, err := http.Get(url)
|
|
if err != nil {
|
|
return GetWeatherOutput{Error: err.Error()}
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var data struct {
|
|
Name string `json:"name"`
|
|
Main struct {
|
|
Temp float64 `json:"temp"`
|
|
Humidity int `json:"humidity"`
|
|
} `json:"main"`
|
|
Weather []struct {
|
|
Description string `json:"description"`
|
|
} `json:"weather"`
|
|
Wind struct {
|
|
Speed float64 `json:"speed"`
|
|
} `json:"wind"`
|
|
}
|
|
|
|
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
|
return GetWeatherOutput{Error: err.Error()}
|
|
}
|
|
|
|
conditions := ""
|
|
if len(data.Weather) > 0 {
|
|
conditions = data.Weather[0].Description
|
|
}
|
|
|
|
return GetWeatherOutput{
|
|
Location: data.Name,
|
|
Temperature: data.Main.Temp,
|
|
Conditions: conditions,
|
|
Humidity: data.Main.Humidity,
|
|
WindSpeed: data.Wind.Speed,
|
|
}
|
|
}
|
|
```
|
|
|
|
### News API Tool
|
|
|
|
```go
|
|
type GetNewsInput struct {
|
|
Query string `json:"query" jsonschema:"Search query for news"`
|
|
Category string `json:"category,omitempty" jsonschema:"Category: business, technology, sports, etc."`
|
|
Count int `json:"count,omitempty" jsonschema:"Number of articles (default: 5, max: 10)"`
|
|
}
|
|
|
|
type NewsArticle struct {
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
Source string `json:"source"`
|
|
URL string `json:"url"`
|
|
PublishedAt string `json:"published_at"`
|
|
}
|
|
|
|
type GetNewsOutput struct {
|
|
Articles []NewsArticle `json:"articles"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
func getNews(ctx tool.Context, input GetNewsInput) GetNewsOutput {
|
|
apiKey := os.Getenv("NEWS_API_KEY")
|
|
if apiKey == "" {
|
|
return GetNewsOutput{Error: "News API not configured"}
|
|
}
|
|
|
|
count := input.Count
|
|
if count <= 0 || count > 10 {
|
|
count = 5
|
|
}
|
|
|
|
url := fmt.Sprintf(
|
|
"https://newsapi.org/v2/everything?q=%s&pageSize=%d&apiKey=%s",
|
|
url.QueryEscape(input.Query),
|
|
count,
|
|
apiKey,
|
|
)
|
|
|
|
resp, err := http.Get(url)
|
|
if err != nil {
|
|
return GetNewsOutput{Error: err.Error()}
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var data struct {
|
|
Articles []struct {
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
URL string `json:"url"`
|
|
PublishedAt string `json:"publishedAt"`
|
|
Source struct {
|
|
Name string `json:"name"`
|
|
} `json:"source"`
|
|
} `json:"articles"`
|
|
}
|
|
|
|
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
|
return GetNewsOutput{Error: err.Error()}
|
|
}
|
|
|
|
articles := make([]NewsArticle, len(data.Articles))
|
|
for i, a := range data.Articles {
|
|
articles[i] = NewsArticle{
|
|
Title: a.Title,
|
|
Description: a.Description,
|
|
Source: a.Source.Name,
|
|
URL: a.URL,
|
|
PublishedAt: a.PublishedAt,
|
|
}
|
|
}
|
|
|
|
return GetNewsOutput{Articles: articles}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Complete Chatbot with All Tools
|
|
|
|
```go
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"google.golang.org/adk/agent/llmagent"
|
|
"google.golang.org/adk/cmd/launcher/adk"
|
|
"google.golang.org/adk/cmd/launcher/full"
|
|
"google.golang.org/adk/model/gemini"
|
|
"google.golang.org/adk/server/restapi/services"
|
|
"google.golang.org/adk/tool"
|
|
"google.golang.org/adk/tool/functiontool"
|
|
"google.golang.org/adk/tool/geminitool"
|
|
"google.golang.org/genai"
|
|
)
|
|
|
|
func main() {
|
|
ctx := context.Background()
|
|
|
|
model, err := gemini.NewModel(ctx, "gemini-3-flash-preview", &genai.ClientConfig{
|
|
APIKey: os.Getenv("GOOGLE_API_KEY"),
|
|
})
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
tools, err := createTools()
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
chatbot, err := llmagent.New(llmagent.Config{
|
|
Name: "smart_assistant",
|
|
Model: model,
|
|
Description: "An intelligent assistant with internet and API access",
|
|
Instruction: `You are a smart, helpful assistant with access to multiple information sources.
|
|
|
|
AVAILABLE TOOLS:
|
|
- google_search: Search the web for any information
|
|
- fetch_url: Read the full content of a specific webpage
|
|
- get_weather: Get current weather for any location
|
|
- get_news: Get recent news articles on any topic
|
|
- remember_fact: Store important information about the user
|
|
- recall_facts: Retrieve stored information about the user
|
|
|
|
BEHAVIOR GUIDELINES:
|
|
1. **Be proactive**: If a question might benefit from current data, search for it
|
|
2. **Combine sources**: Use multiple tools together when helpful
|
|
3. **Cite sources**: When providing factual information, mention where it came from
|
|
4. **Remember context**: Use remember_fact to store user preferences and important info
|
|
5. **Be conversational**: Maintain a friendly, helpful tone
|
|
6. **Handle errors gracefully**: If a tool fails, try alternatives or explain the limitation
|
|
|
|
EXAMPLES OF GOOD BEHAVIOR:
|
|
- User asks "What's the weather?" → Ask for location, then use get_weather
|
|
- User asks about news → Use get_news, summarize, offer to read full articles
|
|
- User mentions their city → Remember it for future weather/news queries
|
|
- User asks about something current → Search first, don't rely on training data
|
|
|
|
User's stored preferences: {user:preferences}
|
|
User's location (if known): {user:location}`,
|
|
Tools: tools,
|
|
})
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
l := full.NewLauncher()
|
|
cfg := &adk.Config{AgentLoader: services.NewSingleAgentLoader(chatbot)}
|
|
if err := l.Execute(ctx, cfg, os.Args[1:]); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func createTools() ([]tool.Tool, error) {
|
|
var tools []tool.Tool
|
|
|
|
// Google Search (built-in)
|
|
tools = append(tools, geminitool.GoogleSearch{})
|
|
|
|
// URL Fetcher
|
|
fetchTool, err := functiontool.New(
|
|
functiontool.Config{
|
|
Name: "fetch_url",
|
|
Description: "Fetch and read the content of a webpage. Use after search to get full article content.",
|
|
},
|
|
fetchURL,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tools = append(tools, fetchTool)
|
|
|
|
// Weather
|
|
weatherTool, err := functiontool.New(
|
|
functiontool.Config{
|
|
Name: "get_weather",
|
|
Description: "Get current weather conditions for a location",
|
|
},
|
|
getWeather,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tools = append(tools, weatherTool)
|
|
|
|
// News
|
|
newsTool, err := functiontool.New(
|
|
functiontool.Config{
|
|
Name: "get_news",
|
|
Description: "Get recent news articles about a topic",
|
|
},
|
|
getNews,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tools = append(tools, newsTool)
|
|
|
|
// Memory tools
|
|
rememberTool, err := functiontool.New(
|
|
functiontool.Config{
|
|
Name: "remember_fact",
|
|
Description: "Store an important fact about the user for future reference",
|
|
},
|
|
rememberFact,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tools = append(tools, rememberTool)
|
|
|
|
recallTool, err := functiontool.New(
|
|
functiontool.Config{
|
|
Name: "recall_facts",
|
|
Description: "Retrieve all stored facts about the user",
|
|
},
|
|
recallFacts,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tools = append(tools, recallTool)
|
|
|
|
return tools, nil
|
|
}
|
|
|
|
// Memory tools
|
|
type RememberFactInput struct {
|
|
Category string `json:"category" jsonschema:"Category: preference, location, name, interest, other"`
|
|
Fact string `json:"fact" jsonschema:"The fact to remember"`
|
|
}
|
|
|
|
type RememberFactOutput struct {
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
func rememberFact(ctx tool.Context, input RememberFactInput) RememberFactOutput {
|
|
state := ctx.Session().State()
|
|
|
|
// Get existing facts
|
|
var facts map[string][]string
|
|
if existing := state.Get("user:facts"); existing != nil {
|
|
if f, ok := existing.(map[string][]string); ok {
|
|
facts = f
|
|
}
|
|
}
|
|
if facts == nil {
|
|
facts = make(map[string][]string)
|
|
}
|
|
|
|
// Add new fact
|
|
facts[input.Category] = append(facts[input.Category], input.Fact)
|
|
state.Set("user:facts", facts)
|
|
|
|
// Also set specific keys for common categories
|
|
if input.Category == "location" {
|
|
state.Set("user:location", input.Fact)
|
|
}
|
|
|
|
return RememberFactOutput{
|
|
Message: fmt.Sprintf("Remembered: %s (%s)", input.Fact, input.Category),
|
|
}
|
|
}
|
|
|
|
type RecallFactsInput struct {
|
|
Category string `json:"category,omitempty" jsonschema:"Optional: filter by category"`
|
|
}
|
|
|
|
type RecallFactsOutput struct {
|
|
Facts map[string][]string `json:"facts"`
|
|
}
|
|
|
|
func recallFacts(ctx tool.Context, input RecallFactsInput) RecallFactsOutput {
|
|
state := ctx.Session().State()
|
|
|
|
var facts map[string][]string
|
|
if existing := state.Get("user:facts"); existing != nil {
|
|
if f, ok := existing.(map[string][]string); ok {
|
|
facts = f
|
|
}
|
|
}
|
|
|
|
if facts == nil {
|
|
return RecallFactsOutput{Facts: map[string][]string{}}
|
|
}
|
|
|
|
if input.Category != "" {
|
|
return RecallFactsOutput{
|
|
Facts: map[string][]string{input.Category: facts[input.Category]},
|
|
}
|
|
}
|
|
|
|
return RecallFactsOutput{Facts: facts}
|
|
}
|
|
|
|
// Include fetchURL, getWeather, getNews from earlier...
|
|
```
|
|
|
|
---
|
|
|
|
## Adding Proactive Search Behavior
|
|
|
|
Use callbacks to automatically search for time-sensitive queries:
|
|
|
|
```go
|
|
import "google.golang.org/adk/model"
|
|
|
|
chatbot, err := llmagent.New(llmagent.Config{
|
|
// ... other config ...
|
|
|
|
BeforeModelCallback: func(ctx agent.CallbackContext, req *model.LLMRequest) (*model.LLMRequest, error) {
|
|
// Log all requests for debugging
|
|
log.Printf("Model request with %d messages", len(req.Messages))
|
|
return req, nil
|
|
},
|
|
|
|
AfterToolCallback: func(ctx agent.CallbackContext, call *tool.Call, result *tool.Result) (*tool.Result, error) {
|
|
// Log tool usage
|
|
log.Printf("Tool %s completed: %v", call.Name, result.Success)
|
|
|
|
// Track tool usage in session for analytics
|
|
state := ctx.Session().State()
|
|
count := 0
|
|
if c := state.Get("temp:tool_count"); c != nil {
|
|
count = c.(int)
|
|
}
|
|
state.Set("temp:tool_count", count+1)
|
|
|
|
return result, nil
|
|
},
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
## Running and Testing
|
|
|
|
```bash
|
|
# Set API keys
|
|
export GOOGLE_API_KEY="your-key"
|
|
export OPENWEATHER_API_KEY="your-key" # Optional
|
|
export NEWS_API_KEY="your-key" # Optional
|
|
|
|
# Run in console
|
|
go run main.go
|
|
|
|
# Example conversations:
|
|
# > What's the weather in Tokyo?
|
|
# > Tell me the latest news about AI
|
|
# > I live in San Francisco (bot remembers this)
|
|
# > What's the weather? (uses remembered location)
|
|
# > Search for the best restaurants near me
|
|
# > Read that first article for me (fetches full content)
|
|
|
|
# Run with web UI for debugging
|
|
go run main.go web api webui
|
|
```
|
|
|
|
---
|
|
|
|
## Best Practices
|
|
|
|
1. **Rate limit external APIs**: Add throttling to prevent abuse
|
|
2. **Cache responses**: Store recent search results to reduce API calls
|
|
3. **Handle failures gracefully**: If one tool fails, the bot should try alternatives
|
|
4. **Be transparent**: Tell users when information comes from search vs. training
|
|
5. **Respect user privacy**: Don't store sensitive information; let users clear memory
|
|
6. **Set reasonable timeouts**: External APIs can be slow; don't hang forever
|