Skip to content

Commit beeb729

Browse files
committed
feat: add Gmail channel, implement goal restart API, and improve model configuration UI
1 parent 50fc2c2 commit beeb729

23 files changed

Lines changed: 764 additions & 139 deletions

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
![GitHub stars](https://img.shields.io/github/stars/grasberg/sofia?style=social)
2+
![GitHub forks](https://img.shields.io/github/forks/grasberg/sofia?style=social)
3+
![License](https://img.shields.io/github/license/grasberg/sofia)
4+
![Go Version](https://img.shields.io/github/go-mod/go-version/grasberg/sofia)
5+
![Last Commit](https://img.shields.io/github/last-commit/grasberg/sofia)
6+
17
# Sofia - AI Workspace Assistant 🧠✨
28

39
![Version](https://img.shields.io/badge/version-v0.0.145-blue)

pkg/agent/instance.go

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,10 @@ type AgentInstance struct {
3838
SkillsFilter []string
3939
IsLocalModel bool
4040
PurposePrompt string
41-
Candidates []providers.FallbackCandidate
42-
Summarization config.SummarizationConfig
43-
ThinkingBudget int
41+
Candidates []providers.FallbackCandidate
42+
CandidateProviders map[string]providers.LLMProvider // "provider/model" → provider
43+
Summarization config.SummarizationConfig
44+
ThinkingBudget int
4445
}
4546

4647
// NewAgentInstance creates an agent instance from config.
@@ -170,13 +171,38 @@ func NewAgentInstance(
170171
temperature = *defaults.Temperature
171172
}
172173

173-
// Resolve fallback candidates
174+
// Resolve fallback candidates.
175+
// Use full "protocol/model" strings (not aliases) so ResolveCandidates
176+
// can extract the provider from the model string.
174177
modelCfg := providers.ModelConfig{
175-
Primary: model,
176-
Fallbacks: fallbacks,
178+
Primary: resolveModelFullString(model, cfg),
179+
Fallbacks: resolveModelFullStrings(fallbacks, cfg),
177180
}
178181
candidates := providers.ResolveCandidates(modelCfg, defaults.Provider)
179182

183+
// Build per-candidate providers so the fallback chain can switch between
184+
// different API endpoints (e.g. Ollama Cloud primary → OpenRouter fallback).
185+
candidateProviders := make(map[string]providers.LLMProvider)
186+
for _, c := range candidates {
187+
key := providers.ModelKey(c.Provider, c.Model)
188+
fullModel := c.Provider + "/" + c.Model
189+
mc := findModelConfigByModel(cfg, fullModel)
190+
if mc == nil {
191+
// Try lookup by alias as fallback
192+
if found, err := cfg.GetModelConfig(c.Model); err == nil {
193+
mc = found
194+
}
195+
}
196+
if mc != nil {
197+
if mc.Workspace == "" {
198+
mc.Workspace = cfg.WorkspacePath()
199+
}
200+
if p, _, err := providers.CreateProviderFromConfig(mc); err == nil && p != nil {
201+
candidateProviders[key] = p
202+
}
203+
}
204+
}
205+
180206
// If this agent has a custom model that differs from the default, create a
181207
// per-agent provider from its model config. This allows different agents to
182208
// use different API keys or providers without sharing the global provider.
@@ -251,15 +277,54 @@ func NewAgentInstance(
251277
SkillsFilter: skillsFilter,
252278
IsLocalModel: isLocal,
253279
PurposePrompt: contextBuilder.purposeInstructions,
254-
Candidates: candidates,
255-
Summarization: summarization,
256-
ThinkingBudget: thinkingBudget,
280+
Candidates: candidates,
281+
CandidateProviders: candidateProviders,
282+
Summarization: summarization,
283+
ThinkingBudget: thinkingBudget,
257284
}
258285
}
259286

260287
// resolveAgentModelID resolves the raw model ID (without protocol prefix) for a given alias.
261288
// It looks up the alias in cfg.ModelList; if found, it extracts the model ID from the
262289
// Model field (e.g. "openai/gpt-4o" -> "gpt-4o"). Falls back to the alias itself if not found.
290+
// findModelConfigByModel searches ModelList by the Model field (protocol/model-id)
291+
// rather than the ModelName alias.
292+
func findModelConfigByModel(cfg *config.Config, model string) *config.ModelConfig {
293+
for i := range cfg.ModelList {
294+
if cfg.ModelList[i].Model == model {
295+
mc := cfg.ModelList[i] // copy
296+
return &mc
297+
}
298+
}
299+
return nil
300+
}
301+
302+
// resolveModelFullString resolves a model alias to its full "protocol/model"
303+
// string from the model list. If the alias is not found, it's returned as-is
304+
// (it may already be a full model string).
305+
func resolveModelFullString(alias string, cfg *config.Config) string {
306+
if alias == "" {
307+
return alias
308+
}
309+
mc, err := cfg.GetModelConfig(alias)
310+
if err == nil && mc != nil && mc.Model != "" {
311+
return mc.Model
312+
}
313+
return alias
314+
}
315+
316+
// resolveModelFullStrings resolves a slice of model aliases.
317+
func resolveModelFullStrings(aliases []string, cfg *config.Config) []string {
318+
if len(aliases) == 0 {
319+
return aliases
320+
}
321+
out := make([]string, len(aliases))
322+
for i, a := range aliases {
323+
out[i] = resolveModelFullString(a, cfg)
324+
}
325+
return out
326+
}
327+
263328
func resolveAgentModelID(alias string, cfg *config.Config) string {
264329
if alias == "" {
265330
return ""

pkg/agent/loop.go

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import (
1111
"fmt"
1212
"os"
1313
"path/filepath"
14-
"strings"
1514
"sync"
1615
"sync/atomic"
1716
"time"
@@ -186,18 +185,11 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
186185
cooldown := providers.NewCooldownTracker()
187186
fallbackChain := providers.NewFallbackChain(cooldown)
188187

189-
// Set up semantic tool matcher if the provider supports embeddings and
190-
// has access to a real embedding model. Local providers like Ollama
191-
// typically don't host embedding models, so the keyword matcher is used instead.
188+
// Tool filtering uses the keyword matcher (tools.KeywordMatchTools) which
189+
// works locally without any API calls. The semantic matcher that called
190+
// OpenAI's text-embedding-3-small has been removed to avoid external
191+
// dependencies and wasted API round-trips on non-OpenAI providers.
192192
var semanticMatcher *tools.SemanticMatcher
193-
if embProvider, ok := provider.(providers.EmbeddingProvider); ok {
194-
modelName := cfg.Agents.Defaults.GetModelName()
195-
mc, _ := cfg.GetModelConfig(modelName)
196-
isLocal := mc != nil && (strings.Contains(mc.APIBase, "localhost") || strings.Contains(mc.APIBase, "127.0.0.1"))
197-
if !isLocal {
198-
semanticMatcher = tools.NewSemanticMatcher(embProvider, "text-embedding-3-small")
199-
}
200-
}
201193

202194
// Create state manager using default agent's workspace for channel recording
203195
defaultAgent := registry.GetDefaultAgent()
@@ -237,11 +229,6 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
237229
toolStatsPath := filepath.Join(filepath.Dir(memDBPath), "tool_stats.json")
238230
toolTracker := tools.NewToolTracker(toolStatsPath)
239231

240-
// Attach tracker to semantic matcher for usage-based ranking
241-
if semanticMatcher != nil {
242-
semanticMatcher.SetTracker(toolTracker)
243-
}
244-
245232
// Set up Audit Logger for tool call tracing
246233
auditDBPath := filepath.Join(filepath.Dir(memDBPath), "audit.db")
247234
auditLog, auditErr := audit.NewAuditLogger(auditDBPath)
@@ -774,3 +761,4 @@ func newAgentInstanceFromEvolution(
774761
}
775762
return NewAgentInstance(&agentCfg, &cfg.Agents.Defaults, cfg, provider, memDB, nil)
776763
}
764+

pkg/agent/loop_llm.go

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -332,8 +332,15 @@ func (al *AgentLoop) runLLMIteration(
332332
candidates = al.providerRanker.Rank(candidates)
333333
}
334334
fbResult, fbErr := al.fallback.Execute(ctx, candidates,
335-
func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) {
336-
return agent.Provider.Chat(ctx, messages, providerToolDefs, model, llmOpts)
335+
func(ctx context.Context, candidateProvider, model string) (*providers.LLMResponse, error) {
336+
// Use the candidate-specific provider if available,
337+
// so fallback can switch between different API endpoints.
338+
p := agent.Provider
339+
key := providers.ModelKey(candidateProvider, model)
340+
if cp, ok := agent.CandidateProviders[key]; ok {
341+
p = cp
342+
}
343+
return p.Chat(ctx, messages, providerToolDefs, model, llmOpts)
337344
},
338345
)
339346
if fbErr != nil {
@@ -373,9 +380,56 @@ func (al *AgentLoop) runLLMIteration(
373380
}
374381

375382
errMsg := strings.ToLower(err.Error())
376-
isContextError := strings.Contains(errMsg, "token") ||
377-
strings.Contains(errMsg, "invalidparameter") ||
378-
strings.Contains(errMsg, "length")
383+
384+
// Check rate limit FIRST — messages like "The Token Plan is
385+
// designed for…" contain the word "token" and must not be
386+
// misclassified as context-window errors.
387+
isRateLimit := strings.Contains(errMsg, "rate_limit") ||
388+
strings.Contains(errMsg, "rate limit") ||
389+
strings.Contains(errMsg, "too many requests")
390+
391+
if isRateLimit && retry < maxRetries {
392+
waitSec := 10 * (retry + 1)
393+
logger.WarnCF(agentComp, "Rate limit hit, backing off before retry", map[string]any{
394+
"error": err.Error(),
395+
"retry": retry,
396+
"wait_seconds": waitSec,
397+
})
398+
if retry == 0 && !constants.IsInternalChannel(opts.Channel) {
399+
al.bus.PublishOutbound(bus.OutboundMessage{
400+
Channel: opts.Channel,
401+
ChatID: opts.ChatID,
402+
Content: "Rate limited by provider. Retrying shortly...",
403+
})
404+
}
405+
select {
406+
case <-time.After(time.Duration(waitSec) * time.Second):
407+
case <-ctx.Done():
408+
return "", iteration, errorCount, ctx.Err()
409+
}
410+
continue
411+
}
412+
413+
// Invalid tool call ID — typically caused by context compression
414+
// orphaning tool_use / tool_result pairs. Sanitize and retry.
415+
isToolIDError := strings.Contains(errMsg, "tool_use_id") ||
416+
strings.Contains(errMsg, "tool_use.id") ||
417+
strings.Contains(errMsg, "tool call id")
418+
419+
if isToolIDError && retry < maxRetries {
420+
logger.WarnCF(agentComp, "Invalid tool call ID detected, sanitizing messages", map[string]any{
421+
"error": err.Error(),
422+
"retry": retry,
423+
"msg_count": len(messages),
424+
})
425+
messages = sanitizeToolCallIDs(messages)
426+
continue
427+
}
428+
429+
isContextError := !isRateLimit &&
430+
(strings.Contains(errMsg, "token") ||
431+
strings.Contains(errMsg, "invalidparameter") ||
432+
strings.Contains(errMsg, "length"))
379433

380434
if isContextError && retry < maxRetries {
381435
logger.WarnCF(agentComp, "Context window error detected, attempting compression", map[string]any{

pkg/agent/loop_query.go

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,16 +42,7 @@ func (al *AgentLoop) ListGoals(agentID string) ([]*autonomy.Goal, error) {
4242
if agentID != "" {
4343
return gm.ListAllGoals(agentID)
4444
}
45-
// Collect goals from all agents
46-
var allGoals []*autonomy.Goal
47-
for _, id := range al.getRegistry().ListAgentIDs() {
48-
goals, err := gm.ListAllGoals(id)
49-
if err != nil {
50-
continue
51-
}
52-
allGoals = append(allGoals, goals...)
53-
}
54-
return allGoals, nil
45+
return gm.ListAllGoalsGlobal()
5546
}
5647

5748
func (al *AgentLoop) GetStartupInfo() map[string]any {
@@ -200,6 +191,41 @@ func (al *AgentLoop) UpdateGoalStatus(goalID int64, status string) error {
200191
return err
201192
}
202193

194+
// RestartGoal transitions a failed goal back to active and resets its plan so
195+
// the autonomy tick re-dispatches the failed steps.
196+
func (al *AgentLoop) RestartGoal(goalID int64) error {
197+
if al.memDB == nil {
198+
return fmt.Errorf("memory database not available")
199+
}
200+
gm := autonomy.NewGoalManager(al.memDB)
201+
202+
goal, err := gm.GetGoalByID(goalID)
203+
if err != nil {
204+
return err
205+
}
206+
if goal == nil {
207+
return fmt.Errorf("goal %d not found", goalID)
208+
}
209+
210+
// Reset the linked plan's failed steps back to pending.
211+
if pm := al.planManager; pm != nil {
212+
if plan := pm.GetPlanByGoalID(goalID); plan != nil {
213+
pm.ResetPlan(plan.ID)
214+
}
215+
}
216+
217+
// Set phase back to implement so the tick picks it up.
218+
if err := gm.UpdateGoalPhase(goalID, autonomy.GoalPhaseImplement); err != nil {
219+
return fmt.Errorf("resetting goal phase: %w", err)
220+
}
221+
222+
// Transition status to active.
223+
if _, err := gm.UpdateGoalStatus(goalID, autonomy.GoalStatusActive); err != nil {
224+
return fmt.Errorf("reactivating goal: %w", err)
225+
}
226+
return nil
227+
}
228+
203229
// DeleteGoal removes a goal and its log from the web UI.
204230
func (al *AgentLoop) DeleteGoal(goalID int64) error {
205231
if al.memDB == nil {

pkg/agent/loop_summarize.go

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,8 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
8888
threshold := agent.ContextWindow * agent.Summarization.ForceTriggerPctOrDefault() / 100
8989

9090
if tokenEstimate <= threshold {
91-
// Tool result truncation was sufficient
91+
// Tool result truncation was sufficient — sanitize IDs before saving.
92+
newHistory = sanitizeToolCallIDs(newHistory)
9293
agent.Sessions.SetHistory(sessionKey, newHistory)
9394
agent.Sessions.Save(sessionKey)
9495
logger.InfoCF("agent", "Context compression: truncated tool results", map[string]any{
@@ -114,6 +115,9 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
114115
newHistory = append(newHistory, enhancedHead...)
115116
newHistory = append(newHistory, tail...)
116117

118+
// Dropping the middle can orphan tool_use / tool_result pairs.
119+
newHistory = sanitizeToolCallIDs(newHistory)
120+
117121
agent.Sessions.SetHistory(sessionKey, newHistory)
118122
agent.Sessions.Save(sessionKey)
119123

@@ -124,6 +128,59 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
124128
})
125129
}
126130

131+
// sanitizeToolCallIDs removes orphaned tool_use / tool_result pairs from a
132+
// message slice. An assistant tool_use whose ID has no matching tool_result is
133+
// stripped (the assistant message is kept if it has text content). A tool_result
134+
// whose ToolCallID has no matching tool_use is dropped entirely.
135+
func sanitizeToolCallIDs(messages []providers.Message) []providers.Message {
136+
// Collect all tool_use IDs from assistant messages.
137+
toolUseIDs := make(map[string]bool)
138+
for _, m := range messages {
139+
if m.Role == "assistant" {
140+
for _, tc := range m.ToolCalls {
141+
if tc.ID != "" {
142+
toolUseIDs[tc.ID] = true
143+
}
144+
}
145+
}
146+
}
147+
148+
// Collect all tool_result IDs.
149+
toolResultIDs := make(map[string]bool)
150+
for _, m := range messages {
151+
if m.Role == "tool" && m.ToolCallID != "" {
152+
toolResultIDs[m.ToolCallID] = true
153+
}
154+
}
155+
156+
out := make([]providers.Message, 0, len(messages))
157+
for _, m := range messages {
158+
switch {
159+
case m.Role == "tool" && m.ToolCallID != "" && !toolUseIDs[m.ToolCallID]:
160+
// Orphaned tool result — no matching assistant tool_use.
161+
continue
162+
163+
case m.Role == "assistant" && len(m.ToolCalls) > 0:
164+
// Strip tool calls that have no matching tool result.
165+
var valid []providers.ToolCall
166+
for _, tc := range m.ToolCalls {
167+
if tc.ID != "" && toolResultIDs[tc.ID] {
168+
valid = append(valid, tc)
169+
}
170+
}
171+
if len(valid) != len(m.ToolCalls) {
172+
cleaned := m // shallow copy
173+
cleaned.ToolCalls = valid
174+
out = append(out, cleaned)
175+
continue
176+
}
177+
}
178+
179+
out = append(out, m)
180+
}
181+
return out
182+
}
183+
127184
// safeCutPoint adjusts a cut index forward so the kept messages don't start
128185
// with an orphaned tool result or sit between an assistant tool-call and its
129186
// results. It returns the adjusted index.

0 commit comments

Comments
 (0)