Skip to content

Commit 270f791

Browse files
Alexey Panfilovclaude
andcommitted
feat: switch primary to Ollama Cloud (Qwen 3.5 397B) with parallel tool calling
- Fix Ollama tool calling: add tool_name field for tool results (Ollama uses tool_name instead of tool_call_id), use real IDs from API when available - Switch routing: level 1+2 → ollama-cloud (qwen3.5:397b-cloud) with vision - Parallel tool execution: multiple tool calls run concurrently via goroutines - Increase max tool iterations from 5 to 10 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 484b4e8 commit 270f791

5 files changed

Lines changed: 163 additions & 47 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,4 @@ openspec
2121
config/routing.json
2222
migrate-to-pg
2323
bin/
24+
/agent

config/config.yaml

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,11 +76,13 @@ models:
7676
max_tokens: 64
7777
no_think: true
7878

79-
# Ollama local — simple tasks via Ollama Cloud model (routed through local Ollama).
80-
ollama-local:
81-
model: qwen3.5:cloud
82-
base_url: http://host.docker.internal:11434
79+
# Ollama Cloud — Qwen 3.5 via Ollama Cloud API.
80+
ollama-cloud:
81+
model: qwen3.5:397b-cloud
82+
base_url: https://api.ollama.com
83+
api_key: ${OLLAMA_API_KEY}
8384
max_tokens: 4096
85+
vision: true
8486

8587
# Claude via bridge (host service wrapping `claude -p` CLI).
8688
# Requires claude-bridge running on host:9900.
@@ -91,8 +93,8 @@ models:
9193
max_tokens: 120
9294

9395
routing:
94-
local: deepseek # level 1: simple tasks → DeepSeek
95-
default: deepseek # level 2: moderate tasks → DeepSeek
96+
local: ollama-cloud # level 1: simple tasks → Qwen via Ollama Cloud
97+
default: ollama-cloud # level 2: moderate tasks → Qwen via Ollama Cloud
9698
reasoner: claude # level 3: complex reasoning → Claude
9799
fallback: gemini-flash-lite
98100
multimodal: gemini-flash # vision/audio fallback (used when primary lacks vision)

internal/agent/agent.go

Lines changed: 44 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"log/slog"
88
"strings"
9+
"sync"
910
"time"
1011
"unicode/utf8"
1112

@@ -20,7 +21,7 @@ func encodeBase64(data []byte) string {
2021
}
2122

2223
const (
23-
maxToolIterations = 5
24+
maxToolIterations = 10
2425
semanticRecentN = 10 // always include last N messages in current session
2526
semanticTopK = 20 // up to K older turns selected by similarity within session
2627

@@ -170,29 +171,7 @@ func (a *Agent) Process(ctx context.Context, chatID int64, userMsg llm.Message,
170171
ToolCalls: resp.ToolCalls,
171172
})
172173

173-
for _, tc := range resp.ToolCalls {
174-
if onToolCall != nil {
175-
onToolCall(tc.Name)
176-
}
177-
a.logger.Info("tool call", "tool", tc.Name)
178-
result, err := a.callTool(ctx, tc.Name, tc.Arguments)
179-
if err != nil {
180-
a.logger.Warn("tool call failed", "tool", tc.Name, "err", err)
181-
result = fmt.Sprintf("Error: %s", err.Error())
182-
}
183-
if len(result) > toolResultSummarizeThreshold {
184-
if summarized, sErr := a.summarizeToolResult(ctx, tc.Name, result); sErr == nil {
185-
a.logger.Info("tool result summarized", "tool", tc.Name, "original_len", len(result), "summary_len", len(summarized))
186-
result = summarized
187-
}
188-
}
189-
a.logger.Info("tool result", "tool", tc.Name, "result_len", len(result))
190-
a.store.AddMessage(chatID, llm.Message{
191-
Role: "tool",
192-
Content: result,
193-
ToolCallID: tc.ID,
194-
})
195-
}
174+
a.executeToolCalls(ctx, chatID, resp.ToolCalls, onToolCall)
196175
}
197176

198177
return "", fmt.Errorf("exceeded maximum tool iterations (%d)", maxToolIterations)
@@ -279,11 +258,36 @@ func (a *Agent) ProcessStream(ctx context.Context, chatID int64, userMsg llm.Mes
279258
ToolCalls: toolCalls,
280259
})
281260

282-
for _, tc := range toolCalls {
283-
if onToolCall != nil {
284-
onToolCall(tc.Name)
285-
}
286-
a.logger.Info("tool call", "tool", tc.Name)
261+
a.executeToolCalls(ctx, chatID, toolCalls, onToolCall)
262+
}
263+
264+
return "", fmt.Errorf("exceeded maximum tool iterations (%d)", maxToolIterations)
265+
}
266+
267+
// SupportsStreaming returns true if the current provider supports streaming.
268+
func (a *Agent) SupportsStreaming() bool {
269+
return a.router.SupportsStreaming()
270+
}
271+
272+
// executeToolCalls runs tool calls in parallel and stores results in order.
273+
func (a *Agent) executeToolCalls(ctx context.Context, chatID int64, toolCalls []llm.ToolCall, onToolCall func(string)) {
274+
type toolResult struct {
275+
tc llm.ToolCall
276+
result string
277+
}
278+
279+
results := make([]toolResult, len(toolCalls))
280+
var wg sync.WaitGroup
281+
282+
for i, tc := range toolCalls {
283+
if onToolCall != nil {
284+
onToolCall(tc.Name)
285+
}
286+
a.logger.Info("tool call", "tool", tc.Name)
287+
288+
wg.Add(1)
289+
go func(idx int, tc llm.ToolCall) {
290+
defer wg.Done()
287291
result, err := a.callTool(ctx, tc.Name, tc.Arguments)
288292
if err != nil {
289293
a.logger.Warn("tool call failed", "tool", tc.Name, "err", err)
@@ -296,20 +300,20 @@ func (a *Agent) ProcessStream(ctx context.Context, chatID int64, userMsg llm.Mes
296300
}
297301
}
298302
a.logger.Info("tool result", "tool", tc.Name, "result_len", len(result))
299-
a.store.AddMessage(chatID, llm.Message{
300-
Role: "tool",
301-
Content: result,
302-
ToolCallID: tc.ID,
303-
})
304-
}
303+
results[idx] = toolResult{tc: tc, result: result}
304+
}(i, tc)
305305
}
306306

307-
return "", fmt.Errorf("exceeded maximum tool iterations (%d)", maxToolIterations)
308-
}
307+
wg.Wait()
309308

310-
// SupportsStreaming returns true if the current provider supports streaming.
311-
func (a *Agent) SupportsStreaming() bool {
312-
return a.router.SupportsStreaming()
309+
// Store results in original order.
310+
for _, r := range results {
311+
a.store.AddMessage(chatID, llm.Message{
312+
Role: "tool",
313+
Content: r.result,
314+
ToolCallID: r.tc.ID,
315+
})
316+
}
313317
}
314318

315319
func (a *Agent) ClearHistory(chatID int64) {

internal/llm/ollama.go

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,11 @@ type ollamaMessage struct {
7272
Content string `json:"content"`
7373
Images []string `json:"images,omitempty"`
7474
ToolCalls []ollamaToolCall `json:"tool_calls,omitempty"`
75+
ToolName string `json:"tool_name,omitempty"`
7576
}
7677

7778
type ollamaToolCall struct {
79+
ID string `json:"id,omitempty"`
7880
Function ollamaFunctionCall `json:"function"`
7981
}
8082

@@ -166,8 +168,12 @@ func (p *ollamaProvider) Chat(ctx context.Context, messages []Message, systemPro
166168
if err != nil {
167169
argsJSON = []byte("{}")
168170
}
171+
callID := tc.ID
172+
if callID == "" {
173+
callID = fmt.Sprintf("call_%d", i)
174+
}
169175
result.ToolCalls = append(result.ToolCalls, ToolCall{
170-
ID: fmt.Sprintf("call_%d", i),
176+
ID: callID,
171177
Name: tc.Function.Name,
172178
Arguments: string(argsJSON),
173179
})
@@ -180,12 +186,29 @@ func (p *ollamaProvider) buildMessages(messages []Message, systemPrompt string)
180186
if systemPrompt != "" {
181187
msgs = append(msgs, ollamaMessage{Role: "system", Content: systemPrompt})
182188
}
189+
// Build a map from tool call ID → tool name for resolving tool results.
190+
toolCallNames := make(map[string]string)
191+
for _, m := range messages {
192+
for _, tc := range m.ToolCalls {
193+
if tc.ID != "" {
194+
toolCallNames[tc.ID] = tc.Name
195+
}
196+
}
197+
}
198+
183199
for _, m := range messages {
184200
msg := ollamaMessage{
185201
Role: m.Role,
186202
Content: m.Content,
187203
}
188204

205+
// Ollama uses "tool_name" instead of "tool_call_id" for tool results.
206+
if m.Role == "tool" && m.ToolCallID != "" {
207+
if name, ok := toolCallNames[m.ToolCallID]; ok {
208+
msg.ToolName = name
209+
}
210+
}
211+
189212
// Handle multimodal parts.
190213
if len(m.Parts) > 0 {
191214
var textParts []string

internal/llm/ollama_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package llm
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
7+
"telegram-agent/internal/config"
8+
)
9+
10+
func TestOllamaBuildMessages_ToolNamePopulated(t *testing.T) {
11+
p, _ := NewOllama(config.ModelConfig{Model: "test"})
12+
13+
messages := []Message{
14+
{Role: "user", Content: "what's the weather?"},
15+
{
16+
Role: "assistant",
17+
ToolCalls: []ToolCall{
18+
{ID: "call_0", Name: "get_weather", Arguments: `{"city":"Belgrade"}`},
19+
},
20+
},
21+
{Role: "tool", Content: `{"temp":22}`, ToolCallID: "call_0"},
22+
}
23+
24+
ollamaMsgs := p.buildMessages(messages, "")
25+
26+
// Find the tool result message.
27+
var toolMsg *ollamaMessage
28+
for i := range ollamaMsgs {
29+
if ollamaMsgs[i].Role == "tool" {
30+
toolMsg = &ollamaMsgs[i]
31+
break
32+
}
33+
}
34+
35+
if toolMsg == nil {
36+
t.Fatal("no tool message found")
37+
}
38+
if toolMsg.ToolName != "get_weather" {
39+
t.Errorf("expected tool_name='get_weather', got '%s'", toolMsg.ToolName)
40+
}
41+
42+
// Verify it serializes correctly.
43+
b, _ := json.Marshal(toolMsg)
44+
var raw map[string]any
45+
json.Unmarshal(b, &raw)
46+
if raw["tool_name"] != "get_weather" {
47+
t.Errorf("JSON tool_name missing or wrong: %s", string(b))
48+
}
49+
}
50+
51+
func TestOllamaBuildMessages_MultipleToolCalls(t *testing.T) {
52+
p, _ := NewOllama(config.ModelConfig{Model: "test"})
53+
54+
messages := []Message{
55+
{Role: "user", Content: "weather and time?"},
56+
{
57+
Role: "assistant",
58+
ToolCalls: []ToolCall{
59+
{ID: "call_0", Name: "get_weather", Arguments: `{"city":"Belgrade"}`},
60+
{ID: "call_1", Name: "get_time", Arguments: `{"tz":"CET"}`},
61+
},
62+
},
63+
{Role: "tool", Content: `{"temp":22}`, ToolCallID: "call_0"},
64+
{Role: "tool", Content: `{"time":"14:00"}`, ToolCallID: "call_1"},
65+
}
66+
67+
ollamaMsgs := p.buildMessages(messages, "")
68+
69+
expected := map[string]string{
70+
`{"temp":22}`: "get_weather",
71+
`{"time":"14:00"}`: "get_time",
72+
}
73+
74+
for _, msg := range ollamaMsgs {
75+
if msg.Role == "tool" {
76+
want, ok := expected[msg.Content]
77+
if !ok {
78+
t.Errorf("unexpected tool content: %s", msg.Content)
79+
continue
80+
}
81+
if msg.ToolName != want {
82+
t.Errorf("content=%s: expected tool_name='%s', got '%s'", msg.Content, want, msg.ToolName)
83+
}
84+
}
85+
}
86+
}

0 commit comments

Comments
 (0)