|
| 1 | +package tools |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "encoding/json" |
| 6 | + "fmt" |
| 7 | + "log/slog" |
| 8 | + "strings" |
| 9 | + "sync" |
| 10 | + "time" |
| 11 | + |
| 12 | + "github.com/73ai/openbotkit/agent" |
| 13 | + "github.com/73ai/openbotkit/provider" |
| 14 | + "github.com/73ai/openbotkit/service/learnings" |
| 15 | +) |
| 16 | + |
| 17 | +// LearningsNotifier sends push notifications for learning events. |
| 18 | +// This avoids importing the channel package (which imports tools). |
| 19 | +type LearningsNotifier interface { |
| 20 | + Push(ctx context.Context, message string) error |
| 21 | +} |
| 22 | + |
| 23 | +type LearningsDeps struct { |
| 24 | + Store *learnings.Store |
| 25 | + BaseURL string |
| 26 | + Notifier LearningsNotifier |
| 27 | +} |
| 28 | + |
| 29 | +type LearningsExtractDeps struct { |
| 30 | + LearningsDeps |
| 31 | + Provider provider.Provider |
| 32 | + Model string |
| 33 | +} |
| 34 | + |
| 35 | +// LearningSaveTool |
| 36 | + |
| 37 | +type LearningSaveTool struct { |
| 38 | + deps LearningsDeps |
| 39 | +} |
| 40 | + |
| 41 | +func NewLearningSaveTool(deps LearningsDeps) *LearningSaveTool { |
| 42 | + return &LearningSaveTool{deps: deps} |
| 43 | +} |
| 44 | + |
| 45 | +func (t *LearningSaveTool) Name() string { return "learnings_save" } |
| 46 | +func (t *LearningSaveTool) Description() string { return "Save learnings as bullet points under a topic" } |
| 47 | +func (t *LearningSaveTool) InputSchema() json.RawMessage { |
| 48 | + return json.RawMessage(`{ |
| 49 | + "type": "object", |
| 50 | + "properties": { |
| 51 | + "topic": { |
| 52 | + "type": "string", |
| 53 | + "description": "Topic name (e.g. Go, SQL, Docker)" |
| 54 | + }, |
| 55 | + "bullets": { |
| 56 | + "type": "array", |
| 57 | + "items": {"type": "string"}, |
| 58 | + "description": "List of learning bullet points" |
| 59 | + } |
| 60 | + }, |
| 61 | + "required": ["topic", "bullets"] |
| 62 | + }`) |
| 63 | +} |
| 64 | + |
| 65 | +type learningSaveInput struct { |
| 66 | + Topic string `json:"topic"` |
| 67 | + Bullets []string `json:"bullets"` |
| 68 | +} |
| 69 | + |
| 70 | +func (t *LearningSaveTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { |
| 71 | + var in learningSaveInput |
| 72 | + if err := json.Unmarshal(input, &in); err != nil { |
| 73 | + return "", fmt.Errorf("parse input: %w", err) |
| 74 | + } |
| 75 | + if in.Topic == "" { |
| 76 | + return "", fmt.Errorf("topic is required") |
| 77 | + } |
| 78 | + if len(in.Bullets) == 0 { |
| 79 | + return "", fmt.Errorf("at least one bullet is required") |
| 80 | + } |
| 81 | + |
| 82 | + if err := t.deps.Store.Save(in.Topic, in.Bullets); err != nil { |
| 83 | + return "", fmt.Errorf("save learning: %w", err) |
| 84 | + } |
| 85 | + |
| 86 | + if t.deps.Notifier != nil { |
| 87 | + slug := t.deps.Store.Slug(in.Topic) |
| 88 | + msg := fmt.Sprintf("Saved a learning about %s", in.Topic) |
| 89 | + if t.deps.BaseURL != "" { |
| 90 | + msg += fmt.Sprintf("\n%s/learnings/%s", t.deps.BaseURL, slug) |
| 91 | + } |
| 92 | + if err := t.deps.Notifier.Push(ctx, msg); err != nil { |
| 93 | + slog.Warn("learnings: notification failed", "error", err) |
| 94 | + } |
| 95 | + } |
| 96 | + |
| 97 | + return fmt.Sprintf("Saved %d bullet(s) under topic %q.", len(in.Bullets), in.Topic), nil |
| 98 | +} |
| 99 | + |
| 100 | +// LearningReadTool |
| 101 | + |
| 102 | +type LearningReadTool struct { |
| 103 | + deps LearningsDeps |
| 104 | +} |
| 105 | + |
| 106 | +func NewLearningReadTool(deps LearningsDeps) *LearningReadTool { |
| 107 | + return &LearningReadTool{deps: deps} |
| 108 | +} |
| 109 | + |
| 110 | +func (t *LearningReadTool) Name() string { return "learnings_read" } |
| 111 | +func (t *LearningReadTool) Description() string { return "Read a learning topic or list all topics" } |
| 112 | +func (t *LearningReadTool) InputSchema() json.RawMessage { |
| 113 | + return json.RawMessage(`{ |
| 114 | + "type": "object", |
| 115 | + "properties": { |
| 116 | + "topic": { |
| 117 | + "type": "string", |
| 118 | + "description": "Topic name to read. Omit to list all topics." |
| 119 | + } |
| 120 | + } |
| 121 | + }`) |
| 122 | +} |
| 123 | + |
| 124 | +type learningReadInput struct { |
| 125 | + Topic string `json:"topic"` |
| 126 | +} |
| 127 | + |
| 128 | +func (t *LearningReadTool) Execute(_ context.Context, input json.RawMessage) (string, error) { |
| 129 | + var in learningReadInput |
| 130 | + if err := json.Unmarshal(input, &in); err != nil { |
| 131 | + return "", fmt.Errorf("parse input: %w", err) |
| 132 | + } |
| 133 | + |
| 134 | + if in.Topic == "" { |
| 135 | + topics, err := t.deps.Store.List() |
| 136 | + if err != nil { |
| 137 | + return "", err |
| 138 | + } |
| 139 | + if len(topics) == 0 { |
| 140 | + return "No learning topics saved yet.", nil |
| 141 | + } |
| 142 | + return "Topics:\n- " + strings.Join(topics, "\n- "), nil |
| 143 | + } |
| 144 | + |
| 145 | + content, err := t.deps.Store.Read(in.Topic) |
| 146 | + if err != nil { |
| 147 | + return "", err |
| 148 | + } |
| 149 | + return content, nil |
| 150 | +} |
| 151 | + |
| 152 | +// LearningSearchTool |
| 153 | + |
| 154 | +type LearningSearchTool struct { |
| 155 | + deps LearningsDeps |
| 156 | +} |
| 157 | + |
| 158 | +func NewLearningSearchTool(deps LearningsDeps) *LearningSearchTool { |
| 159 | + return &LearningSearchTool{deps: deps} |
| 160 | +} |
| 161 | + |
| 162 | +func (t *LearningSearchTool) Name() string { return "learnings_search" } |
| 163 | +func (t *LearningSearchTool) Description() string { |
| 164 | + return "Search across all saved learnings" |
| 165 | +} |
| 166 | +func (t *LearningSearchTool) InputSchema() json.RawMessage { |
| 167 | + return json.RawMessage(`{ |
| 168 | + "type": "object", |
| 169 | + "properties": { |
| 170 | + "query": { |
| 171 | + "type": "string", |
| 172 | + "description": "Search query" |
| 173 | + } |
| 174 | + }, |
| 175 | + "required": ["query"] |
| 176 | + }`) |
| 177 | +} |
| 178 | + |
| 179 | +type learningSearchInput struct { |
| 180 | + Query string `json:"query"` |
| 181 | +} |
| 182 | + |
| 183 | +func (t *LearningSearchTool) Execute(_ context.Context, input json.RawMessage) (string, error) { |
| 184 | + var in learningSearchInput |
| 185 | + if err := json.Unmarshal(input, &in); err != nil { |
| 186 | + return "", fmt.Errorf("parse input: %w", err) |
| 187 | + } |
| 188 | + if in.Query == "" { |
| 189 | + return "", fmt.Errorf("query is required") |
| 190 | + } |
| 191 | + |
| 192 | + results, err := t.deps.Store.Search(in.Query) |
| 193 | + if err != nil { |
| 194 | + return "", err |
| 195 | + } |
| 196 | + if len(results) == 0 { |
| 197 | + return "No results found.", nil |
| 198 | + } |
| 199 | + |
| 200 | + var b strings.Builder |
| 201 | + for _, r := range results { |
| 202 | + fmt.Fprintf(&b, "[%s] %s\n", r.Topic, r.Line) |
| 203 | + } |
| 204 | + return b.String(), nil |
| 205 | +} |
| 206 | + |
| 207 | +// LearningExtractTool |
| 208 | + |
| 209 | +type LearningExtractTool struct { |
| 210 | + deps LearningsExtractDeps |
| 211 | +} |
| 212 | + |
| 213 | +func NewLearningExtractTool(deps LearningsExtractDeps) *LearningExtractTool { |
| 214 | + return &LearningExtractTool{deps: deps} |
| 215 | +} |
| 216 | + |
| 217 | +func (t *LearningExtractTool) Name() string { return "learnings_extract" } |
| 218 | +func (t *LearningExtractTool) Description() string { |
| 219 | + return "Extract and save key learnings from source material (runs in background)" |
| 220 | +} |
| 221 | +func (t *LearningExtractTool) InputSchema() json.RawMessage { |
| 222 | + return json.RawMessage(`{ |
| 223 | + "type": "object", |
| 224 | + "properties": { |
| 225 | + "context": { |
| 226 | + "type": "string", |
| 227 | + "description": "Source material to extract learnings from" |
| 228 | + } |
| 229 | + }, |
| 230 | + "required": ["context"] |
| 231 | + }`) |
| 232 | +} |
| 233 | + |
| 234 | +type learningExtractInput struct { |
| 235 | + Context string `json:"context"` |
| 236 | +} |
| 237 | + |
| 238 | +func (t *LearningExtractTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { |
| 239 | + var in learningExtractInput |
| 240 | + if err := json.Unmarshal(input, &in); err != nil { |
| 241 | + return "", fmt.Errorf("parse input: %w", err) |
| 242 | + } |
| 243 | + if in.Context == "" { |
| 244 | + return "", fmt.Errorf("context is required") |
| 245 | + } |
| 246 | + |
| 247 | + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) |
| 248 | + go func() { |
| 249 | + defer cancel() |
| 250 | + t.run(ctx, in.Context) |
| 251 | + }() |
| 252 | + |
| 253 | + return "Extraction started. You'll get a notification when done.", nil |
| 254 | +} |
| 255 | + |
| 256 | +// trackingSaveTool wraps LearningSaveTool to record which topics were saved. |
| 257 | +type trackingSaveTool struct { |
| 258 | + inner *LearningSaveTool |
| 259 | + mu sync.Mutex |
| 260 | + slugs []string |
| 261 | +} |
| 262 | + |
| 263 | +func (t *trackingSaveTool) Name() string { return t.inner.Name() } |
| 264 | +func (t *trackingSaveTool) Description() string { return t.inner.Description() } |
| 265 | +func (t *trackingSaveTool) InputSchema() json.RawMessage { return t.inner.InputSchema() } |
| 266 | +func (t *trackingSaveTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { |
| 267 | + var in learningSaveInput |
| 268 | + if err := json.Unmarshal(input, &in); err == nil && in.Topic != "" { |
| 269 | + slug := t.inner.deps.Store.Slug(in.Topic) |
| 270 | + t.mu.Lock() |
| 271 | + t.slugs = append(t.slugs, slug) |
| 272 | + t.mu.Unlock() |
| 273 | + } |
| 274 | + return t.inner.Execute(ctx, input) |
| 275 | +} |
| 276 | + |
| 277 | +func (t *LearningExtractTool) run(ctx context.Context, material string) { |
| 278 | + if t.deps.Provider == nil { |
| 279 | + slog.Error("learnings: extraction skipped, no provider configured") |
| 280 | + return |
| 281 | + } |
| 282 | + |
| 283 | + subDeps := LearningsDeps{Store: t.deps.Store} |
| 284 | + tracker := &trackingSaveTool{inner: NewLearningSaveTool(subDeps)} |
| 285 | + toolReg := NewRegistry() |
| 286 | + toolReg.Register(tracker) |
| 287 | + toolReg.Register(NewLearningReadTool(subDeps)) |
| 288 | + |
| 289 | + system := `You are a learning extraction assistant. Read the provided material and extract key learnings. |
| 290 | +Learnings are knowledge, techniques, insights, or skills the user picked up. Things like how something works, a useful pattern, a gotcha to avoid. |
| 291 | +Do NOT save: personal info (name, timezone, contacts), factual lookups (currency conversions, weather), trivial Q&A, or preferences. Those are not learnings. |
| 292 | +If the material has nothing worth saving as a learning, just reply "Nothing to save here" and do not call any tools. |
| 293 | +For each distinct topic, call learnings_save with a topic name and concise bullet points. |
| 294 | +Use learnings_read first to check existing topics so you can append to them rather than creating duplicates. |
| 295 | +Keep bullet points casual, concise, and useful. No emdashes. |
| 296 | +After saving, reply with a short friendly 1-2 sentence summary of what you saved. If you saved nothing, say so. This message will be sent as a notification.` |
| 297 | + |
| 298 | + blocks := []provider.SystemBlock{ |
| 299 | + {Text: system, CacheControl: &provider.CacheControl{Type: "ephemeral"}}, |
| 300 | + } |
| 301 | + |
| 302 | + a := agent.New(t.deps.Provider, t.deps.Model, toolReg, |
| 303 | + agent.WithSystemBlocks(blocks), |
| 304 | + agent.WithMaxIterations(15), |
| 305 | + ) |
| 306 | + |
| 307 | + result, err := a.Run(ctx, material) |
| 308 | + if err != nil { |
| 309 | + slog.Error("learnings: extraction failed", "error", err) |
| 310 | + return |
| 311 | + } |
| 312 | + |
| 313 | + if t.deps.Notifier != nil { |
| 314 | + msg := strings.TrimSpace(result) |
| 315 | + if msg == "" { |
| 316 | + msg = "Saved your learnings!" |
| 317 | + } |
| 318 | + if t.deps.BaseURL != "" { |
| 319 | + for _, slug := range tracker.slugs { |
| 320 | + msg += fmt.Sprintf("\n%s/learnings/%s", t.deps.BaseURL, slug) |
| 321 | + } |
| 322 | + } |
| 323 | + if err := t.deps.Notifier.Push(ctx, msg); err != nil { |
| 324 | + slog.Warn("learnings: extract notification failed", "error", err) |
| 325 | + } |
| 326 | + } |
| 327 | + |
| 328 | + slog.Info("learnings: extraction complete") |
| 329 | +} |
0 commit comments