Add tests for scheduler and tool call functionalities

- Introduced unit tests for the MCP scheduler in `internal/mcp/scheduler_test.go` to validate task addition and schema inclusion for the "run_once" feature.
- Added tests in `internal/scheduler/scheduler_test.go` to ensure proper tool discovery and compatibility with server names.
- Created tests in `internal/scheduler/scheduler_wd_test.go` to verify task scoping to working directories and default working directory behavior.
- Implemented `internal/toolcalls/toolcalls.go` to extract tool calls from various formats, including JSON and custom formats, with comprehensive parsing logic.
- Added tests for tool call extraction in `internal/toolcalls/toolcalls_test.go` to validate functionality across different input scenarios.
This commit is contained in:
Cheikh Seck committed 2026-04-19 01:38:17 +00:00
1 parent 9c3297ee54
commit af37a3f7a9
13 files changed
+1243 -169

No files matched your search

+18
View File
@@ -326,6 +326,12 @@ func main() {
log.Printf("[Scheduler] Failed to initialize: %v", err)
} else {
sched.SetProviderGetter(&schedulerProviderGetter{provider: provider})
var serverSlice []interface{} = make([]interface{}, len(servers))
for i, s := range servers {
serverSlice[i] = s
}
sched.SetServers(serverSlice)
schedulerServer := mcp.NewSchedulerServer(sched, &schedulerProviderGetter{provider: provider})
servers = append(servers, schedulerServer)
for _, tool := range schedulerServer.Tools() {
@@ -359,6 +365,18 @@ func main() {
// Get working directory for session files
wd, _ := os.Getwd()
if sched != nil {
maxRounds := 10
if provider.MaxToolRounds != nil && *provider.MaxToolRounds > 0 {
maxRounds = *provider.MaxToolRounds
}
toolTimeout := 180
if provider.ToolTimeout != nil && *provider.ToolTimeout > 0 {
toolTimeout = *provider.ToolTimeout
}
sched.SetConfig(maxRounds, toolTimeout, wd, runtime.GOOS)
}
// Initialize history database (stored in tmp directory)
var historyDB *history.HistoryDB
historyDB, err = history.NewDefault()
+30 -5
View File
@@ -168,9 +168,10 @@ func (m promptModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.statusHidden {
return m, nil
}
if (m.statusMessage != "" || len(m.commitList) > 0) && msg.Type != tea.KeyTab {
if (m.statusMessage != "" || len(m.commitList) > 0 || len(m.scheduleList) > 0) && msg.Type != tea.KeyTab {
m.statusMessage = ""
m.commitList = nil
m.scheduleList = nil
}
if msg.Type == tea.KeyCtrlR {
if m.searchMode {
@@ -411,6 +412,14 @@ func (m promptModel) View() string {
b.WriteByte('\n')
}
}
if len(m.scheduleList) > 0 {
b.WriteByte('\n')
listStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("75"))
for _, id := range m.scheduleList {
b.WriteString(listStyle.Render(id))
b.WriteByte('\n')
}
}
if m.statusMessage != "" {
b.WriteByte('\n')
b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(m.statusMessage))
@@ -597,11 +606,27 @@ func (m *promptModel) handleTab() {
}
func (m *promptModel) handleScheduleTab(value string) bool {
hasTrailingSpace := strings.HasSuffix(value, " ")
parts := strings.Fields(value)
if len(parts) != 2 {
if hasTrailingSpace {
parts = append(parts, "")
}
var (
prefix string
nextPrefix string
)
if len(parts) == 2 {
prefix = strings.ToUpper(parts[1])
nextPrefix = "/schedule "
} else if len(parts) == 3 && (parts[1] == "stop" || parts[1] == "remove") {
prefix = strings.ToUpper(parts[2])
nextPrefix = "/schedule " + parts[1] + " "
} else {
return false
}
prefix := strings.ToUpper(parts[1])
if sched == nil {
return false
}
@@ -622,8 +647,8 @@ func (m *promptModel) handleScheduleTab(value string) bool {
if len(matches) == 0 {
return false
}
if len(matches) == 1 && prefix != "" {
m.input.SetValue("/schedule " + matches[0])
if len(matches) == 1 {
m.input.SetValue(nextPrefix + matches[0])
m.input.SetCursor(len([]rune(m.input.Value())))
m.resetCompletion()
return true
+37 -37
View File
@@ -37,37 +37,7 @@ func (s *schedulerProviderGetter) GetProvider(cfg interface{}) (interface{}, err
}
func handleSchedule(input string) {
if sched == nil {
fmt.Println("[Scheduler] Not initialized")
return
}
parts := strings.Fields(input)
if len(parts) == 1 {
listScheduledTasks()
return
}
if len(parts) == 2 && len(parts[1]) == 4 {
id := strings.ToUpper(parts[1])
task := sched.GetTask(id)
if task == nil {
fmt.Printf("Task %s not found\n", id)
return
}
st, ok := task.(*scheduler.ScheduledTask)
if !ok {
fmt.Printf("Task %s not found\n", id)
return
}
if st.LastOutput == "" {
fmt.Printf("No output for task %s\n", id)
return
}
fmt.Printf("Output for task %s:\n%s\n", id, st.LastOutput)
return
}
if len(parts) >= 2 && (parts[1] == "help" || parts[1] == "-h" || parts[1] == "--help") {
fmt.Println("Scheduler - Schedule prompts to run at specific times or intervals")
fmt.Println("")
@@ -75,18 +45,28 @@ func handleSchedule(input string) {
fmt.Println(" /schedule - List all scheduled tasks")
fmt.Println(" /schedule <id> - Show last output of a task")
fmt.Println(" /schedule stop <id> - Stop a running task by 4-char ID")
fmt.Println(" /schedule remove <id> - Remove a task from the schedule by 4-char ID")
fmt.Println(" /schedule help - Show this help message")
fmt.Println(" /schedule remove <id> - Remove a task from the schedule by 4-char ID")
fmt.Println(" /schedule help - Show this help message")
fmt.Println("")
fmt.Println("LLM Tool (schedule_task):")
fmt.Println("LLM Tool (scheduler):")
fmt.Println(" prompt: The prompt to execute")
fmt.Println(" interval_sec: Repeating interval in seconds (e.g., 60, 3600)")
fmt.Println(" run_at: Time in HH:MM format (e.g., '14:30') or 'now' for immediate execution")
fmt.Println("")
fmt.Println("Examples:")
fmt.Println(" schedule_task(prompt='Summarize my emails', interval_sec=3600)")
fmt.Println(" schedule_task(prompt='Check system status', run_at='09:00')")
fmt.Println(" schedule_task(prompt='Generate report', run_at='now')")
fmt.Println(" scheduler(prompt='Summarize my emails', interval_sec=3600)")
fmt.Println(" scheduler(prompt='Check system status', run_at='09:00')")
fmt.Println(" scheduler(prompt='Generate report', run_at='now')")
return
}
if sched == nil {
fmt.Println("[Scheduler] Not initialized")
return
}
if len(parts) == 1 {
listScheduledTasks()
return
}
@@ -118,11 +98,31 @@ func handleSchedule(input string) {
return
}
if len(parts) == 2 && len(parts[1]) == 4 {
id := strings.ToUpper(parts[1])
task := sched.GetTask(id)
if task == nil {
fmt.Printf("Task %s not found\n", id)
return
}
st, ok := task.(*scheduler.ScheduledTask)
if !ok || st == nil {
fmt.Printf("Task %s not found\n", id)
return
}
if st.LastOutput == "" {
fmt.Printf("No output for task %s\n", id)
return
}
fmt.Printf("Output for task %s:\n%s\n", id, renderMarkdown(st.LastOutput))
return
}
fmt.Println("Usage:")
fmt.Println(" /schedule - List all scheduled tasks")
fmt.Println(" /schedule <id> - Show last output of a task")
fmt.Println(" /schedule stop <id> - Stop a task by 4-char ID")
fmt.Println(" /schedule remove <id> - Remove a task by 4-char ID")
fmt.Println(" /schedule remove <id> - Remove a task by 4-char ID")
}
func listScheduledTasks() {
+46 -16
View File
@@ -15,7 +15,6 @@ type SchedulerServer struct {
type schedulerExt struct {
scheduler interface {
AddTask(prompt string, intervalSec int, runAt string, providerType, providerName, providerModel string) (interface{}, error)
RunNow(prompt string, providerType, providerName, providerModel string) (string, error)
StopTask(id string) bool
RemoveTask(id string) bool
ListTasks() []interface{}
@@ -48,7 +47,6 @@ type TaskInfo struct {
func NewSchedulerServer(scheduler interface {
AddTask(prompt string, intervalSec int, runAt string, providerType, providerName, providerModel string) (interface{}, error)
RunNow(prompt string, providerType, providerName, providerModel string) (string, error)
StopTask(id string) bool
RemoveTask(id string) bool
ListTasks() []interface{}
@@ -72,7 +70,7 @@ func (s *SchedulerServer) Tools() []Tool {
return []Tool{
{
Name: "scheduler",
Description: fmt.Sprintf("Schedule a task to run a prompt at a specific time or at regular intervals. Current time: %s. Use interval_sec for repeating tasks (e.g., 60 for every minute, 3600 for every hour), or run_at for specific times (e.g., \"14:30\" for 2:30 PM daily, or \"now\" to execute immediately).", currentTime),
Description: fmt.Sprintf("Schedule a task to run a prompt at a specific time or at regular intervals. Current time: %s. Use interval_sec for repeating tasks (e.g., 60 for every minute, 3600 for every hour), or run_at for specific times (e.g., \"14:30\" for 2:30 PM daily, or \"now\" to execute immediately). Set run_once=true to run only once (useful with interval_sec as a one-shot delay).", currentTime),
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {
@@ -87,6 +85,10 @@ func (s *SchedulerServer) Tools() []Tool {
"run_at": {
"type": "string",
"description": "Specific time to run in HH:MM format (e.g., '14:30' for 2:30 PM), or 'now' to execute immediately."
},
"run_once": {
"type": "boolean",
"description": "If true, the task runs once and does not repeat (even if interval_sec is set)."
}
},
"required": ["prompt"]
@@ -117,21 +119,38 @@ func (s *SchedulerServer) CallTool(ctx context.Context, name string, args map[st
runAt = strings.TrimSpace(rt)
}
isNow := strings.ToLower(runAt) == "now"
runOnce := false
if v, ok := args["run_once"].(bool); ok {
runOnce = v
} else if v, ok := args["run_once"].(string); ok {
runOnce = strings.ToLower(strings.TrimSpace(v)) == "true"
}
if intervalSec <= 0 && runAt == "" {
return "", fmt.Errorf("must specify either interval_sec or run_at")
}
if isNow {
result, err := s.scheduler.scheduler.RunNow(prompt, "current", "current", "current")
if err != nil {
return fmt.Sprintf("Error running task: %v", err), nil
}
return fmt.Sprintf("Task executed immediately.\n\nResult:\n%s", result), nil
isRepeating := intervalSec > 0
if runOnce {
isRepeating = false
}
if strings.ToLower(runAt) == "now" {
isRepeating = false
}
task, err := s.scheduler.scheduler.AddTask(prompt, intervalSec, runAt, "current", "current", "current")
var (
task interface{}
err error
)
type schedulerWithRepeat interface {
AddTaskWithRepeat(prompt string, intervalSec int, runAt string, isRepeating bool, providerType, providerName, providerModel string) (interface{}, error)
}
if sr, ok := s.scheduler.scheduler.(schedulerWithRepeat); ok {
task, err = sr.AddTaskWithRepeat(prompt, intervalSec, runAt, isRepeating, "current", "current", "current")
} else {
task, err = s.scheduler.scheduler.AddTask(prompt, intervalSec, runAt, "current", "current", "current")
}
if err != nil {
return fmt.Sprintf("Error scheduling task: %v", err), nil
}
@@ -140,13 +159,24 @@ func (s *SchedulerServer) CallTool(ctx context.Context, name string, args map[st
return "Error: task was not created", nil
}
taskInfo, ok := task.(TaskInfo)
if !ok {
return fmt.Sprintf("Task scheduled successfully but could not get ID"), nil
var st struct {
ID string `json:"id"`
Prompt string `json:"prompt"`
IntervalSec int `json:"interval_sec"`
RunAt string `json:"run_at"`
IsRepeating bool `json:"is_repeating"`
}
b, marshalErr := json.Marshal(task)
if marshalErr != nil || json.Unmarshal(b, &st) != nil || strings.TrimSpace(st.ID) == "" {
return "Task scheduled successfully but could not get ID", nil
}
return fmt.Sprintf("Task scheduled successfully with ID: %s\nPrompt: %s\nInterval: %d seconds\nRun at: %s",
taskInfo.ID, taskInfo.Prompt, taskInfo.IntervalSec, taskInfo.RunAt), nil
mode := "repeating"
if !st.IsRepeating {
mode = "once"
}
return fmt.Sprintf("Task scheduled successfully with ID: %s\nMode: %s\nPrompt: %s\nInterval: %d seconds\nRun at: %s",
st.ID, mode, st.Prompt, st.IntervalSec, st.RunAt), nil
}
func (s *SchedulerServer) AllowedPaths() []string {
+61
View File
@@ -0,0 +1,61 @@
package mcp
import (
"context"
"strings"
"testing"
)
type fakeScheduler struct {
lastIsRepeating bool
}
type fakeTask struct {
ID string `json:"id"`
Prompt string `json:"prompt"`
IntervalSec int `json:"interval_sec"`
RunAt string `json:"run_at"`
IsRepeating bool `json:"is_repeating"`
}
func (f *fakeScheduler) AddTask(prompt string, intervalSec int, runAt string, providerType, providerName, providerModel string) (interface{}, error) {
return f.AddTaskWithRepeat(prompt, intervalSec, runAt, intervalSec > 0, providerType, providerName, providerModel)
}
func (f *fakeScheduler) AddTaskWithRepeat(prompt string, intervalSec int, runAt string, isRepeating bool, providerType, providerName, providerModel string) (interface{}, error) {
f.lastIsRepeating = isRepeating
return &fakeTask{ID: "ABCD", Prompt: prompt, IntervalSec: intervalSec, RunAt: runAt, IsRepeating: isRepeating}, nil
}
func (f *fakeScheduler) StopTask(id string) bool { return true }
func (f *fakeScheduler) RemoveTask(id string) bool { return true }
func (f *fakeScheduler) ListTasks() []interface{} { return nil }
func (f *fakeScheduler) GetTask(id string) interface{} { return nil }
func TestSchedulerTool_RunOnceForcesNonRepeating(t *testing.T) {
fs := &fakeScheduler{}
server := NewSchedulerServer(fs, nil)
_, err := server.CallTool(context.Background(), "scheduler", map[string]interface{}{
"prompt": "hi",
"interval_sec": float64(60),
"run_once": true,
})
if err != nil {
t.Fatalf("CallTool: %v", err)
}
if fs.lastIsRepeating {
t.Fatalf("expected isRepeating=false when run_once=true")
}
}
func TestSchedulerTool_SchemaIncludesRunOnce(t *testing.T) {
server := NewSchedulerServer(&fakeScheduler{}, nil)
tools := server.Tools()
if len(tools) != 1 {
t.Fatalf("expected 1 tool, got %d", len(tools))
}
if !strings.Contains(string(tools[0].InputSchema), `"run_once"`) {
t.Fatalf("expected InputSchema to include run_once, got: %s", string(tools[0].InputSchema))
}
}
+1 -1
View File
@@ -220,7 +220,7 @@ func (o *ollamaProvider) Send(ctx context.Context, prompt string) (string, error
defer o.sendMu.Unlock()
o.mu.Lock()
systemPrompt, userInput := splitSystemUserPrompt(prompt)
systemPrompt, userInput := SplitSystemUserPrompt(prompt)
if systemPrompt != "" {
o.systemPrompt = systemPrompt
}
+1 -1
View File
@@ -162,7 +162,7 @@ func newOpenRouterProvider(cfg *config.Provider) (Provider, error) {
}
func (p *openRouterProvider) Send(ctx context.Context, prompt string) (string, error) {
systemPrompt, userInput := splitSystemUserPrompt(prompt)
systemPrompt, userInput := SplitSystemUserPrompt(prompt)
if userInput == "" {
userInput = strings.TrimSpace(prompt)
}
+2 -1
View File
@@ -7,7 +7,8 @@ const (
userAskedMarker = "User asked:"
)
func splitSystemUserPrompt(prompt string) (string, string) {
// SplitSystemUserPrompt splits a prompt into system and user parts
func SplitSystemUserPrompt(prompt string) (string, string) {
prompt = strings.TrimSpace(prompt)
if prompt == "" {
return "", ""
+459 -108
View File
@@ -9,9 +9,13 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"github.com/cheikh2shift/godex/internal/mcp"
"github.com/cheikh2shift/godex/internal/providers"
"github.com/cheikh2shift/godex/internal/toolcalls"
_ "modernc.org/sqlite"
)
@@ -21,6 +25,7 @@ type ScheduledTask struct {
IntervalSec int `json:"interval_sec"`
RunAt string `json:"run_at"`
IsRepeating bool `json:"is_repeating"`
WorkingDir string `json:"working_dir"`
CreatedAt time.Time `json:"created_at"`
LastRun time.Time `json:"last_run"`
RunCount int `json:"run_count"`
@@ -32,20 +37,31 @@ type ScheduledTask struct {
}
type Scheduler struct {
db *sql.DB
tasks map[string]*ScheduledTask
taskMu sync.RWMutex
running map[string]context.CancelFunc
runMu sync.RWMutex
stopCh chan struct{}
wg sync.WaitGroup
provider ProviderGetter
db *sql.DB
tasks map[string]*ScheduledTask
taskMu sync.RWMutex
running map[string]context.CancelFunc
runMu sync.RWMutex
stopCh chan struct{}
wg sync.WaitGroup
provider ProviderGetter
servers []ToolServer
maxRounds int
toolTimeout int
wd string
os string
}
type ProviderGetter interface {
GetProvider(cfg interface{}) (interface{}, error)
}
type ToolServer interface {
Name() string
Tools() []mcp.Tool
CallTool(ctx context.Context, name string, args map[string]interface{}) (string, error)
}
func getDefaultDBPath() string {
if runtime.GOOS == "windows" {
return filepath.Join(os.Getenv("LOCALAPPDATA"), "godex", "scheduler.db")
@@ -63,6 +79,8 @@ func New(dbPath string) (*Scheduler, error) {
dbPath = getDefaultDBPath()
}
wd, _ := os.Getwd()
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, err
@@ -82,6 +100,7 @@ func New(dbPath string) (*Scheduler, error) {
tasks: make(map[string]*ScheduledTask),
running: make(map[string]context.CancelFunc),
stopCh: make(chan struct{}),
wd: wd,
}
if err := s.initSchema(); err != nil {
@@ -121,6 +140,7 @@ func (s *Scheduler) initSchema() error {
interval_sec INTEGER NOT NULL,
run_at TEXT NOT NULL,
is_repeating INTEGER NOT NULL,
working_dir TEXT NOT NULL,
created_at TEXT NOT NULL,
last_run TEXT,
run_count INTEGER DEFAULT 0,
@@ -131,33 +151,69 @@ func (s *Scheduler) initSchema() error {
provider_model TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_created_at ON scheduled_tasks(created_at);
CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_working_dir ON scheduled_tasks(working_dir);
`
_, err := s.db.Exec(schema)
return err
if err != nil {
return err
}
// Migrate older DBs which predate working_dir.
if _, err := s.db.Exec(`ALTER TABLE scheduled_tasks ADD COLUMN working_dir TEXT`); err != nil {
// ignore "duplicate column name" errors (SQLite reports this when column already exists)
if !strings.Contains(strings.ToLower(err.Error()), "duplicate column") {
return err
}
}
_, _ = s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_working_dir ON scheduled_tasks(working_dir)`)
if strings.TrimSpace(s.wd) != "" {
_, _ = s.db.Exec(`UPDATE scheduled_tasks SET working_dir = ? WHERE working_dir IS NULL OR working_dir = ''`, s.wd)
}
return nil
}
func (s *Scheduler) loadTasks() error {
rows, err := s.db.Query(`
SELECT id, prompt, interval_sec, run_at, is_repeating, created_at,
query := `
SELECT id, prompt, interval_sec, run_at, is_repeating, working_dir, created_at,
last_run, run_count, last_error, last_output, provider_type, provider_name, provider_model
FROM scheduled_tasks
`)
`
var (
rows *sql.Rows
err error
)
if strings.TrimSpace(s.wd) != "" {
rows, err = s.db.Query(query+` WHERE working_dir = ?`, s.wd)
} else {
rows, err = s.db.Query(query)
}
if err != nil {
return err
}
defer rows.Close()
s.taskMu.Lock()
defer s.taskMu.Unlock()
for rows.Next() {
var task ScheduledTask
var lastRun, createdAt, runAt, lastOutput sql.NullString
var (
lastRun, createdAt, runAt, lastOutput sql.NullString
workingDir sql.NullString
isRepeatingInt sql.NullInt64
)
err := rows.Scan(
&task.ID, &task.Prompt, &task.IntervalSec, &runAt, &task.IsRepeating,
&task.ID, &task.Prompt, &task.IntervalSec, &runAt, &isRepeatingInt, &workingDir,
&createdAt, &lastRun, &task.RunCount, &task.LastError, &lastOutput,
&task.ProviderType, &task.ProviderName, &task.ProviderModel,
)
if err != nil {
return err
}
if isRepeatingInt.Valid && isRepeatingInt.Int64 != 0 {
task.IsRepeating = true
}
if createdAt.Valid {
task.CreatedAt, _ = time.Parse(time.RFC3339, createdAt.String)
}
@@ -170,6 +226,11 @@ func (s *Scheduler) loadTasks() error {
if lastOutput.Valid {
task.LastOutput = lastOutput.String
}
if workingDir.Valid {
task.WorkingDir = workingDir.String
} else {
task.WorkingDir = s.wd
}
s.tasks[task.ID] = &task
}
return rows.Err()
@@ -179,6 +240,42 @@ func (s *Scheduler) SetProviderGetter(pg ProviderGetter) {
s.provider = pg
}
func (s *Scheduler) SetServers(servers []interface{}) {
s.servers = nil
for _, srv := range servers {
ts, ok := srv.(ToolServer)
if !ok {
continue
}
s.servers = append(s.servers, ts)
}
}
func (s *Scheduler) SetConfig(maxRounds, toolTimeout int, wd, os string) {
if maxRounds > 0 {
s.maxRounds = maxRounds
}
if toolTimeout > 0 {
s.toolTimeout = toolTimeout
}
if strings.TrimSpace(wd) != "" && s.wd != wd {
// Re-scope tasks to the configured working directory.
s.wd = wd
s.StopAllTasks()
s.taskMu.Lock()
s.tasks = make(map[string]*ScheduledTask)
s.taskMu.Unlock()
_ = s.initSchema()
_ = s.loadTasks()
s.startLoadedTasks()
} else if strings.TrimSpace(wd) != "" {
s.wd = wd
}
if os != "" {
s.os = os
}
}
func (s *Scheduler) generateID() (string, error) {
charset := "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
bytes := make([]byte, 4)
@@ -194,7 +291,7 @@ func (s *Scheduler) generateID() (string, error) {
type SchedulerInterface interface {
AddTask(prompt string, intervalSec int, runAt string, providerType, providerName, providerModel string) (interface{}, error)
RunNow(prompt string, providerType, providerName, providerModel string) (string, error)
AddTaskWithRepeat(prompt string, intervalSec int, runAt string, isRepeating bool, providerType, providerName, providerModel string) (interface{}, error)
StopTask(id string) bool
RemoveTask(id string) bool
ListTasks() []interface{}
@@ -202,6 +299,11 @@ type SchedulerInterface interface {
}
func (s *Scheduler) AddTask(prompt string, intervalSec int, runAt string, providerType, providerName, providerModel string) (interface{}, error) {
isRepeating := intervalSec > 0
return s.AddTaskWithRepeat(prompt, intervalSec, runAt, isRepeating, providerType, providerName, providerModel)
}
func (s *Scheduler) AddTaskWithRepeat(prompt string, intervalSec int, runAt string, isRepeating bool, providerType, providerName, providerModel string) (interface{}, error) {
if intervalSec <= 0 && runAt == "" {
return nil, fmt.Errorf("must specify either interval or run_at time")
}
@@ -216,7 +318,8 @@ func (s *Scheduler) AddTask(prompt string, intervalSec int, runAt string, provid
Prompt: prompt,
IntervalSec: intervalSec,
RunAt: runAt,
IsRepeating: intervalSec > 0,
IsRepeating: isRepeating,
WorkingDir: s.wd,
CreatedAt: time.Now(),
RunCount: 0,
ProviderType: providerType,
@@ -237,40 +340,6 @@ func (s *Scheduler) AddTask(prompt string, intervalSec int, runAt string, provid
return task, nil
}
func (s *Scheduler) RunNow(prompt string, providerType, providerName, providerModel string) (string, error) {
if s.provider == nil {
return "", fmt.Errorf("no provider getter set")
}
cfg := map[string]interface{}{
"type": providerType,
"name": providerName,
"model": providerModel,
}
prov, err := s.provider.GetProvider(cfg)
if err != nil {
return "", fmt.Errorf("failed to get provider: %v", err)
}
provInterface, ok := prov.(interface {
Send(ctx context.Context, prompt string) (string, error)
})
if !ok {
return "", fmt.Errorf("provider does not support Send method")
}
taskCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
result, err := provInterface.Send(taskCtx, prompt)
if err != nil {
return "", fmt.Errorf("execution error: %v", err)
}
return result, nil
}
func (s *Scheduler) saveTask(task *ScheduledTask) error {
var lastRunStr string
if task.LastRun.IsZero() {
@@ -280,9 +349,9 @@ func (s *Scheduler) saveTask(task *ScheduledTask) error {
}
_, err := s.db.Exec(`
INSERT OR REPLACE INTO scheduled_tasks
(id, prompt, interval_sec, run_at, is_repeating, created_at, last_run, run_count, last_error, last_output, provider_type, provider_name, provider_model)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, task.ID, task.Prompt, task.IntervalSec, task.RunAt, task.IsRepeating,
(id, prompt, interval_sec, run_at, is_repeating, working_dir, created_at, last_run, run_count, last_error, last_output, provider_type, provider_name, provider_model)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, task.ID, task.Prompt, task.IntervalSec, task.RunAt, task.IsRepeating, task.WorkingDir,
task.CreatedAt.Format(time.RFC3339), lastRunStr,
task.RunCount, task.LastError, task.LastOutput, task.ProviderType, task.ProviderName, task.ProviderModel)
return err
@@ -307,6 +376,9 @@ func (s *Scheduler) runLoop(ctx context.Context, task *ScheduledTask) {
defer s.wg.Done()
sleepDuration := func() time.Duration {
if strings.ToLower(strings.TrimSpace(task.RunAt)) == "now" {
return 0
}
if task.IntervalSec > 0 {
return time.Duration(task.IntervalSec) * time.Second
}
@@ -339,60 +411,64 @@ func (s *Scheduler) runLoop(ctx context.Context, task *ScheduledTask) {
}
func (s *Scheduler) executeTask(task *ScheduledTask) {
if s.provider == nil {
task.LastError = "no provider getter set"
task.LastRun = time.Now()
task.RunCount++
s.updateTask(task)
return
}
var cfg interface{}
if task.ProviderType == "unknown" || task.ProviderType == "" || task.ProviderType == "current" {
cfg = map[string]interface{}{
"type": "current",
"name": "current",
"model": "current",
}
} else {
cfg = map[string]interface{}{
"type": task.ProviderType,
"name": task.ProviderName,
"model": task.ProviderModel,
}
}
prov, err := s.provider.GetProvider(cfg)
if err != nil {
task.LastError = fmt.Sprintf("failed to get provider: %v", err)
task.LastRun = time.Now()
task.RunCount++
s.updateTask(task)
return
}
provInterface, ok := prov.(interface {
Send(ctx context.Context, prompt string) (string, error)
})
if !ok {
task.LastError = "provider does not support Send method"
task.LastRun = time.Now()
task.RunCount++
s.updateTask(task)
return
}
taskCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
result, err := provInterface.Send(taskCtx, task.Prompt)
if err != nil {
task.LastError = fmt.Sprintf("execution error: %v", err)
task.LastOutput = ""
if len(s.servers) > 0 {
result, err := s.executeWithTools(taskCtx, task)
if err != nil {
task.LastError = fmt.Sprintf("execution error: %v", err)
task.LastOutput = ""
} else {
task.LastError = ""
task.LastOutput = result
}
} else if s.provider != nil {
var cfg interface{}
if task.ProviderType == "unknown" || task.ProviderType == "" || task.ProviderType == "current" {
cfg = map[string]interface{}{
"type": "current",
"name": "current",
"model": "current",
}
} else {
cfg = map[string]interface{}{
"type": task.ProviderType,
"name": task.ProviderName,
"model": task.ProviderModel,
}
}
prov, err := s.provider.GetProvider(cfg)
if err != nil {
task.LastError = fmt.Sprintf("failed to get provider: %v", err)
task.LastRun = time.Now()
task.RunCount++
s.updateTask(task)
return
}
provInterface, ok := prov.(interface {
Send(ctx context.Context, prompt string) (string, error)
})
if !ok {
task.LastError = "provider does not support Send method"
task.LastRun = time.Now()
task.RunCount++
s.updateTask(task)
return
}
result, err := provInterface.Send(taskCtx, task.Prompt)
if err != nil {
task.LastError = fmt.Sprintf("execution error: %v", err)
task.LastOutput = ""
} else {
task.LastError = ""
task.LastOutput = result
}
} else {
task.LastError = ""
task.LastOutput = result
//fmt.Printf("[Scheduler %s] Result: %s\n", task.ID, truncate(result, 200))
task.LastError = "no provider or servers available"
}
task.LastRun = time.Now()
@@ -400,6 +476,272 @@ func (s *Scheduler) executeTask(task *ScheduledTask) {
s.updateTask(task)
}
func (s *Scheduler) executeWithTools(ctx context.Context, task *ScheduledTask) (string, error) {
maxRounds := s.maxRounds
if maxRounds <= 0 {
maxRounds = 10
}
toolTimeout := s.toolTimeout
if toolTimeout <= 0 {
toolTimeout = 180
}
if s.provider == nil {
return "", fmt.Errorf("no provider available")
}
cfg := map[string]interface{}{
"type": "current",
"name": "current",
"model": "current",
}
prov, err := s.provider.GetProvider(cfg)
if err != nil {
return "", fmt.Errorf("failed to get provider: %v", err)
}
llmProvider, ok := prov.(interface {
Send(ctx context.Context, prompt string) (string, error)
SetThinkCallback(cb func(string))
SupportsNativeToolCalls() bool
})
if !ok {
return "", fmt.Errorf("provider does not support required interface")
}
toolsDesc := s.getToolsDescription()
toolCallFormat := ""
if !llmProvider.SupportsNativeToolCalls() {
toolCallFormat = "\nTo call tools, respond with JSON in markdown code blocks:\n```json\n{\n \"name\": \"tool_name\",\n \"arguments\": {\n \"arg1\": \"value1\"\n }\n}\n```"
}
input := task.Prompt
fullPrompt := s.buildInitialPrompt(input, toolsDesc, toolCallFormat, llmProvider.SupportsNativeToolCalls())
prevRoundToolCalls := make(map[string]bool)
prevNoTool := false
for round := 0; round < maxRounds; round++ {
var resp string
resp, err = llmProvider.Send(ctx, fullPrompt)
if err != nil {
return "", fmt.Errorf("provider error: %v", err)
}
preResp, finalResp, hasFinal := s.splitFinalAnswer(resp)
toolCalls := toolcalls.ExtractAllToolCalls(preResp)
isToolCallResponse := len(toolCalls) > 0
if !isToolCallResponse || len(toolCalls) == 0 {
if !hasFinal {
if !s.looksLikeToolCall(resp) {
return resp, nil
}
if strings.TrimSpace(resp) != "" && prevNoTool {
return resp, nil
}
prevNoTool = true
continuePrompt := fmt.Sprintf("You provided the following response:\n%s\n\nHowever, I couldn't find any valid tool calls in it. Please provide your FINAL_ANSWER now based on the information you have.", resp)
fullPrompt = s.buildContinuePrompt(continuePrompt, toolsDesc, toolCallFormat, llmProvider.SupportsNativeToolCalls(), round+1, maxRounds)
continue
}
return finalResp, nil
}
prevNoTool = false
var toolResults []string
for _, tc := range toolCalls {
toolName, ok := tc["name"].(string)
if !ok {
continue
}
args, ok := tc["arguments"].(map[string]interface{})
if !ok {
continue
}
result, err := s.callTool(ctx, toolName, args, toolTimeout)
if err != nil {
toolResults = append(toolResults, fmt.Sprintf("ERROR: %v", err))
} else {
toolResults = append(toolResults, s.truncate(result, 2500))
}
}
if hasFinal {
return finalResp, nil
}
currentToolCalls := make(map[string]bool)
for _, tc := range toolCalls {
name := tc["name"].(string)
args := tc["arguments"]
argsJson, _ := json.Marshal(args)
currentToolCalls[fmt.Sprintf("%s:%s", name, string(argsJson))] = true
}
hasRepeatedCalls := false
for sig := range currentToolCalls {
if prevRoundToolCalls[sig] {
hasRepeatedCalls = true
break
}
}
duplicateWarning := ""
if hasRepeatedCalls && round > 0 {
duplicateWarning = "\n\nWARNING: You are calling the same tool(s) with the same arguments as the previous round. Do NOT call them again."
}
roundUrgency := ""
if round >= 10 {
roundUrgency = "\n\nSTOP: You have reached round " + fmt.Sprintf("%d", round+1) + ". You MUST provide your FINAL_ANSWER now."
} else if round >= 2 {
roundUrgency = "\n\nNOTE: You are on round " + fmt.Sprintf("%d", round+1) + ". Only call more tools if absolutely necessary."
}
prevRoundToolCalls = currentToolCalls
if llmProvider.SupportsNativeToolCalls() {
fullPrompt = fmt.Sprintf("User asked: %s\n\nTool results:\n%s%s%s\n\nProvide your FINAL_ANSWER now.", input, strings.Join(toolResults, "\n---\n"), duplicateWarning, roundUrgency)
} else {
fullPrompt = fmt.Sprintf("You have access to these tools:%s\n\n%s\n\nUser asked: %s\n\nTool results:\n%s%s%s\n\nProvide your FINAL_ANSWER now.", toolsDesc, toolCallFormat, input, strings.Join(toolResults, "\n---\n"), duplicateWarning, roundUrgency)
}
}
return "Max tool rounds reached", nil
}
func (s *Scheduler) buildInitialPrompt(input, toolsDesc string, toolCallFormat string, nativeToolCalls bool) string {
osInfo := "linux"
if s.os != "" {
osInfo = s.os
}
arch := runtime.GOARCH
wdInfo := ""
if s.wd != "" {
wdInfo = s.wd
}
base := fmt.Sprintf(`CRITICAL INFORMATION:
- Operating System: %s (%s)
- Current working directory: %s
Use this path when the user asks about "this folder", "current directory", or similar.
IMPORTANT: Execute tools FIRST, perform any action asked for by the user, then provide the final answer. Do NOT include any final answer, summary, or "FINAL_ANSWER:" until AFTER you have executed all necessary tools and received their results.
`, osInfo, arch, wdInfo)
if nativeToolCalls {
return base + "\n\n" + input
}
return fmt.Sprintf("%s\n\nYou have access to these tools:\n%s\n\n%s\n\nUser asked: %s", base, toolsDesc, toolCallFormat, input)
}
func (s *Scheduler) buildContinuePrompt(continuePrompt, toolsDesc string, toolCallFormat string, nativeToolCalls bool, currentRound, maxRounds int) string {
roundInfo := ""
if currentRound > 0 {
roundInfo = fmt.Sprintf("\n\nNOTE: You are on round %d/%d. Only call more tools if absolutely necessary.", currentRound, maxRounds)
}
osInfo := "linux"
if s.os != "" {
osInfo = s.os
}
arch := runtime.GOARCH
wdInfo := ""
if s.wd != "" {
wdInfo = s.wd
}
extraInfo := fmt.Sprintf(`CRITICAL INFORMATION:
- Operating System: %s (%s)
- Current working directory: %s
Use this path when the user asks about "this folder", "current directory", or similar.
`, osInfo, arch, wdInfo)
systemPrompt, _ := providers.SplitSystemUserPrompt(continuePrompt)
if systemPrompt != "" {
systemPrompt += "\n\n"
}
userPromptWithContext := fmt.Sprintf("\n\n%s%sContinue from where you left off. %s\n\nProvide your FINAL_ANSWER now.%s", extraInfo, systemPrompt, continuePrompt, roundInfo)
if nativeToolCalls {
return userPromptWithContext
}
return fmt.Sprintf("%s\n\nYou have access to these tools:\n%s\n\n%s\n\n%s", extraInfo, toolsDesc, toolCallFormat, userPromptWithContext)
}
func (s *Scheduler) splitFinalAnswer(text string) (string, string, bool) {
parts := strings.Split(text, "FINAL_ANSWER:")
if len(parts) > 1 {
return parts[0], strings.TrimSpace(parts[1]), true
}
parts = strings.Split(text, "final answer:")
if len(parts) > 1 {
return parts[0], strings.TrimSpace(parts[1]), true
}
return text, "", false
}
func (s *Scheduler) looksLikeToolCall(text string) bool {
return strings.Contains(text, "tool") || strings.Contains(text, "call") ||
strings.Contains(text, "function") || strings.Contains(text, "(")
}
func (s *Scheduler) callTool(ctx context.Context, name string, args map[string]interface{}, timeoutSecs int) (string, error) {
for _, server := range s.servers {
for _, t := range server.Tools() {
if t.Name == name {
toolCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSecs)*time.Second)
defer cancel()
return server.CallTool(toolCtx, name, args)
}
}
}
// Compatibility: some models incorrectly use the server name (e.g. "bash") as the tool name.
lower := strings.ToLower(strings.TrimSpace(name))
for _, server := range s.servers {
if strings.ToLower(server.Name()) != lower {
continue
}
for _, t := range server.Tools() {
if t.Name == "run_command" {
toolCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSecs)*time.Second)
defer cancel()
return server.CallTool(toolCtx, t.Name, args)
}
}
}
return "", fmt.Errorf("tool %s not found", name)
}
func (s *Scheduler) truncate(str string, maxLen int) string {
if len(str) <= maxLen {
return str
}
return str[:maxLen] + "..."
}
func (s *Scheduler) getToolsDescription() string {
if len(s.servers) == 0 {
return "No tools available."
}
var desc strings.Builder
for _, server := range s.servers {
tools := server.Tools()
if len(tools) == 0 {
continue
}
desc.WriteString(fmt.Sprintf("\n[%s]\n", server.Name()))
for _, tool := range tools {
desc.WriteString(fmt.Sprintf(" - %s: %s\n", tool.Name, tool.Description))
}
}
return desc.String()
}
func (s *Scheduler) updateTask(task *ScheduledTask) {
s.taskMu.Lock()
s.tasks[task.ID] = task
@@ -419,12 +761,25 @@ func (s *Scheduler) StopTask(id string) bool {
return false
}
func (s *Scheduler) StopAllTasks() {
s.runMu.Lock()
for id, cancel := range s.running {
cancel()
delete(s.running, id)
}
s.runMu.Unlock()
}
func (s *Scheduler) RemoveTask(id string) bool {
s.StopTask(id)
s.taskMu.Lock()
if _, exists := s.tasks[id]; exists {
delete(s.tasks, id)
s.taskMu.Unlock()
if strings.TrimSpace(s.wd) != "" {
_, err := s.db.Exec("DELETE FROM scheduled_tasks WHERE id = ? AND working_dir = ?", id, s.wd)
return err == nil
}
_, err := s.db.Exec("DELETE FROM scheduled_tasks WHERE id = ?", id)
return err == nil
}
@@ -451,11 +806,7 @@ func (s *Scheduler) GetTask(id string) interface{} {
func (s *Scheduler) Close() {
close(s.stopCh)
s.runMu.Lock()
for _, cancel := range s.running {
cancel()
}
s.runMu.Unlock()
s.StopAllTasks()
s.wg.Wait()
s.db.Close()
}
+95
View File
@@ -0,0 +1,95 @@
package scheduler
import (
"context"
"strings"
"testing"
"time"
"github.com/cheikh2shift/godex/internal/mcp"
)
type fakeToolServer struct {
name string
tools []mcp.Tool
}
func (f *fakeToolServer) Name() string { return f.name }
func (f *fakeToolServer) Tools() []mcp.Tool {
return f.tools
}
func (f *fakeToolServer) CallTool(ctx context.Context, name string, args map[string]interface{}) (string, error) {
if name == "run_command" {
return "ok", nil
}
return "bad:" + name, nil
}
func TestScheduler_GetToolsDescription_MatchesMainFormat(t *testing.T) {
s := &Scheduler{}
s.SetServers([]interface{}{
&fakeToolServer{
name: "bash",
tools: []mcp.Tool{
{Name: "run_command", Description: "Run a shell command"},
},
},
})
desc := s.getToolsDescription()
if desc == "" {
t.Fatalf("expected non-empty tools description")
}
if want := "[bash]"; !strings.Contains(desc, want) {
t.Fatalf("expected tools description to contain %q, got: %s", want, desc)
}
if want := "run_command"; !strings.Contains(desc, want) {
t.Fatalf("expected tools description to contain %q, got: %s", want, desc)
}
}
func TestScheduler_CallTool_FindsTool(t *testing.T) {
s := &Scheduler{}
s.SetServers([]interface{}{
&fakeToolServer{
name: "bash",
tools: []mcp.Tool{
{Name: "run_command", Description: "Run a shell command"},
},
},
})
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
out, err := s.callTool(ctx, "run_command", map[string]interface{}{"command": "echo hi"}, 1)
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if out != "ok" {
t.Fatalf("expected ok, got %q", out)
}
}
func TestScheduler_CallTool_ServerNameCompatibility(t *testing.T) {
s := &Scheduler{}
s.SetServers([]interface{}{
&fakeToolServer{
name: "bash",
tools: []mcp.Tool{
{Name: "run_command", Description: "Run a shell command"},
},
},
})
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
out, err := s.callTool(ctx, "Bash", map[string]interface{}{"command": "echo hi"}, 1)
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if out != "ok" {
t.Fatalf("expected ok, got %q", out)
}
}
+74
View File
@@ -0,0 +1,74 @@
package scheduler
import (
"os"
"path/filepath"
"testing"
)
func TestScheduler_ScopesTasksToWorkingDir(t *testing.T) {
tmp := t.TempDir()
dbPath := filepath.Join(tmp, "scheduler.db")
wd1 := filepath.Join(tmp, "repo1")
wd2 := filepath.Join(tmp, "repo2")
// Create a repeating task scoped to wd1.
s1, err := New(dbPath)
if err != nil {
t.Fatalf("New: %v", err)
}
s1.SetConfig(0, 0, wd1, "")
if _, err := s1.AddTask("hello", 60, "", "current", "current", "current"); err != nil {
t.Fatalf("AddTask: %v", err)
}
s1.Close()
// Re-open with the same wd; task should be visible and repeating.
s2, err := New(dbPath)
if err != nil {
t.Fatalf("New (2): %v", err)
}
s2.SetConfig(0, 0, wd1, "")
tasks := s2.ListTasks()
if len(tasks) != 1 {
t.Fatalf("expected 1 task for wd1, got %d", len(tasks))
}
task, ok := tasks[0].(*ScheduledTask)
if !ok {
t.Fatalf("expected *ScheduledTask, got %T", tasks[0])
}
if task.WorkingDir != wd1 {
t.Fatalf("expected WorkingDir=%q, got %q", wd1, task.WorkingDir)
}
if !task.IsRepeating {
t.Fatalf("expected IsRepeating=true after reload")
}
s2.Close()
// Re-open with a different wd; task should not be visible.
s3, err := New(dbPath)
if err != nil {
t.Fatalf("New (3): %v", err)
}
s3.SetConfig(0, 0, wd2, "")
if got := len(s3.ListTasks()); got != 0 {
t.Fatalf("expected 0 tasks for wd2, got %d", got)
}
s3.Close()
}
func TestScheduler_DefaultWDUsesProcessWD(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "scheduler.db")
s, err := New(dbPath)
if err != nil {
t.Fatalf("New: %v", err)
}
defer s.Close()
pwd, _ := os.Getwd()
if pwd != "" && s.wd != pwd {
t.Fatalf("expected scheduler wd to default to process wd %q, got %q", pwd, s.wd)
}
}
+370
View File
@@ -0,0 +1,370 @@
package toolcalls
import (
"encoding/json"
"regexp"
"strings"
)
// ExtractAllToolCalls extracts tool calls from a model response in a variety of common formats.
// It returns items shaped as: {"name": string, "arguments": map[string]interface{}}.
func ExtractAllToolCalls(text string) []map[string]interface{} {
var results []map[string]interface{}
candidates := []string{text}
if normalized := normalizeToolCallText(text); normalized != text {
candidates = append(candidates, normalized)
}
for _, candidate := range candidates {
// First, try to find markdown code blocks using proper extraction
codeBlocks := extractCodeBlockContent(candidate)
for _, content := range codeBlocks {
content = strings.TrimSpace(content)
// Try to parse the whole block as one JSON object
var data map[string]interface{}
if err := json.Unmarshal([]byte(content), &data); err == nil {
if _, ok := data["name"].(string); ok {
results = append(results, processToolData(data))
continue
}
}
// Try to parse the block as a JSON array of tool calls
if arrayCalls := extractToolCallsFromJSONArray(content); len(arrayCalls) > 0 {
results = append(results, arrayCalls...)
continue
}
// If not a single object, try sanitizing multi-line strings
sanitized := sanitizeMultilineJson(content)
var sanitizedData map[string]interface{}
if err := json.Unmarshal([]byte(sanitized), &sanitizedData); err == nil {
if _, ok := sanitizedData["name"].(string); ok {
results = append(results, processToolData(sanitizedData))
continue
}
}
if arrayCalls := extractToolCallsFromJSONArray(sanitized); len(arrayCalls) > 0 {
results = append(results, arrayCalls...)
continue
}
// Fall back to extracting individual JSON objects
extracted := extractJsonObjects(sanitized)
if len(extracted) > 0 {
results = append(results, extracted...)
}
}
// If no tools found in code blocks, search the whole text for { ... } blobs
if len(results) == 0 {
results = append(results, extractJsonObjects(candidate)...)
}
if len(results) == 0 {
results = append(results, extractToolCallsFromJSONArray(candidate)...)
}
// Safety fallback for the specific [TOOL_CALL] format
if len(results) == 0 {
toolCallRe := regexp.MustCompile(`(?s)\[TOOL_CALL\]\s*(.*?)\s*\[/TOOL_CALL\]`)
tcMatches := toolCallRe.FindAllStringSubmatch(candidate, -1)
for _, m := range tcMatches {
if len(m) > 1 {
nameRe := regexp.MustCompile(`tool\s*=>\s*"([^"]+)"`)
if nameMatch := nameRe.FindStringSubmatch(m[1]); len(nameMatch) > 1 {
results = append(results, map[string]interface{}{"name": nameMatch[1], "arguments": map[string]interface{}{}})
}
}
}
}
// Parse native tool call format: [TOOL_CALL: name | {"arg": "value"}]
if len(results) == 0 {
nativeMatches := extractNativeToolCalls(candidate)
for _, m := range nativeMatches {
name := m[0]
argsStr := m[1]
var args map[string]interface{}
if err := json.Unmarshal([]byte(argsStr), &args); err != nil {
args = map[string]interface{}{"_raw": argsStr}
}
results = append(results, map[string]interface{}{"name": name, "arguments": args})
}
}
if len(results) > 0 {
break
}
}
return results
}
func extractToolCallsFromJSONArray(text string) []map[string]interface{} {
trimmed := strings.TrimSpace(text)
if trimmed == "" {
return nil
}
var items []map[string]interface{}
if err := json.Unmarshal([]byte(trimmed), &items); err != nil {
return nil
}
var results []map[string]interface{}
for _, item := range items {
if item == nil {
continue
}
if _, ok := item["name"].(string); ok {
results = append(results, processToolData(item))
continue
}
if tool, ok := item["tool"].(string); ok {
args := parseToolArgs(item)
if len(args) == 0 {
if params, ok := item["parameters"].(map[string]interface{}); ok {
args = params
}
}
results = append(results, map[string]interface{}{
"name": tool,
"arguments": args,
})
}
}
return results
}
func extractNativeToolCalls(text string) [][2]string {
var results [][2]string
re := regexp.MustCompile(`\[TOOL_CALL:\s*([^\s|]+)\s*\|`)
matches := re.FindAllStringSubmatchIndex(text, -1)
for _, m := range matches {
if len(m) < 4 {
continue
}
name := text[m[2]:m[3]]
jsonStart := m[1]
for jsonStart < len(text) && (text[jsonStart] == ' ' || text[jsonStart] == '\t') {
jsonStart++
}
var jsonEnd int
inString := false
escapeNext := false
braceDepth := 0
for i := jsonStart; i < len(text); i++ {
c := text[i]
if escapeNext {
escapeNext = false
continue
}
switch c {
case '\\':
escapeNext = true
case '"':
inString = !inString
case '{', '[':
if !inString {
braceDepth++
}
case '}', ']':
if !inString {
braceDepth--
if braceDepth == 0 {
jsonEnd = i + 1
break
}
}
}
}
if jsonEnd > jsonStart {
results = append(results, [2]string{name, text[jsonStart:jsonEnd]})
}
}
return results
}
func normalizeToolCallText(text string) string {
lines := strings.Split(text, "\n")
if len(lines) == 0 {
return text
}
nonEmpty := 0
withMargin := 0
marginRe := regexp.MustCompile(`^\s*│`)
stripRe := regexp.MustCompile(`^\s*│\s?`)
for _, line := range lines {
if strings.TrimSpace(line) == "" {
continue
}
nonEmpty++
if marginRe.MatchString(line) {
withMargin++
}
}
if nonEmpty == 0 || withMargin*2 < nonEmpty {
return text
}
for i, line := range lines {
lines[i] = stripRe.ReplaceAllString(line, "")
}
return strings.Join(lines, "\n")
}
func sanitizeMultilineJson(text string) string {
var result strings.Builder
inString := false
escapeNext := false
lines := strings.Split(text, "\n")
for lineIdx, line := range lines {
for i := 0; i < len(line); i++ {
char := rune(line[i])
if escapeNext {
if char == 'n' {
result.WriteString("\\n")
} else {
result.WriteRune('\\')
result.WriteRune(char)
}
escapeNext = false
continue
}
if char == '\\' && inString {
escapeNext = true
continue
}
if char == '"' {
if !inString {
inString = true
} else {
backslashCount := 0
j := i - 1
for j >= 0 && line[j] == '\\' {
backslashCount++
j--
}
if backslashCount%2 == 0 {
inString = false
}
}
}
result.WriteRune(char)
}
if inString && lineIdx < len(lines)-1 {
result.WriteString("\\n")
} else if !inString {
result.WriteRune('\n')
}
}
return result.String()
}
func extractCodeBlockContent(text string) []string {
var results []string
lines := strings.Split(text, "\n")
inBlock := false
blockStart := -1
for i, line := range lines {
if !inBlock {
if strings.Contains(line, "```") {
inBlock = true
blockStart = i + 1
}
} else {
if strings.TrimSpace(line) == "```" {
content := strings.Join(lines[blockStart:i], "\n")
results = append(results, content)
inBlock = false
blockStart = -1
}
}
}
return results
}
func extractJsonObjects(text string) []map[string]interface{} {
var results []map[string]interface{}
startIdx := -1
braceCount := 0
for i, char := range text {
if char == '{' {
if braceCount == 0 {
startIdx = i
}
braceCount++
} else if char == '}' {
if braceCount > 0 {
braceCount--
if braceCount == 0 && startIdx != -1 {
potentialJSON := text[startIdx : i+1]
var data map[string]interface{}
if err := json.Unmarshal([]byte(potentialJSON), &data); err == nil {
if _, ok := data["name"].(string); ok {
results = append(results, processToolData(data))
}
}
}
}
}
}
return results
}
func processToolData(data map[string]interface{}) map[string]interface{} {
name, _ := data["name"].(string)
args := parseToolArgs(data)
// Clean up string arguments
for k, v := range args {
if s, ok := v.(string); ok {
s = strings.ReplaceAll(s, "\\u0026", "&")
s = strings.ReplaceAll(s, "&#38;", "&")
s = strings.ReplaceAll(s, "&amp;", "&")
args[k] = s
}
}
return map[string]interface{}{"name": name, "arguments": args}
}
func parseToolArgs(data map[string]interface{}) map[string]interface{} {
if a, ok := data["arguments"].(map[string]interface{}); ok {
return a
}
if a, ok := data["args"].(map[string]interface{}); ok {
return a
}
if a, ok := data["parameters"].(map[string]interface{}); ok {
return a
}
if a, ok := data["params"].(map[string]interface{}); ok {
return a
}
if raw, ok := data["parameters"].(string); ok && strings.TrimSpace(raw) != "" {
var parsed map[string]interface{}
if err := json.Unmarshal([]byte(raw), &parsed); err == nil {
return parsed
}
}
if raw, ok := data["arguments"].(string); ok && strings.TrimSpace(raw) != "" {
var parsed map[string]interface{}
if err := json.Unmarshal([]byte(raw), &parsed); err == nil {
return parsed
}
}
return make(map[string]interface{})
}
+49
View File
@@ -0,0 +1,49 @@
package toolcalls
import "testing"
func TestExtractAllToolCalls_PlainJSON(t *testing.T) {
in := `I'll do it.
{
"name": "run_command",
"arguments": {
"command": "echo hi"
}
}`
calls := ExtractAllToolCalls(in)
if len(calls) != 1 {
t.Fatalf("expected 1 tool call, got %d", len(calls))
}
if calls[0]["name"] != "run_command" {
t.Fatalf("expected name=run_command, got %#v", calls[0]["name"])
}
args, ok := calls[0]["arguments"].(map[string]interface{})
if !ok {
t.Fatalf("expected arguments map, got %#v", calls[0]["arguments"])
}
if args["command"] != "echo hi" {
t.Fatalf("expected command=echo hi, got %#v", args["command"])
}
}
func TestExtractAllToolCalls_FencedJSON(t *testing.T) {
in := "```json\n{\"name\":\"run_command\",\"arguments\":{\"command\":\"echo hi\"}}\n```"
calls := ExtractAllToolCalls(in)
if len(calls) != 1 {
t.Fatalf("expected 1 tool call, got %d", len(calls))
}
if calls[0]["name"] != "run_command" {
t.Fatalf("expected name=run_command, got %#v", calls[0]["name"])
}
}
func TestExtractAllToolCalls_JSONArray(t *testing.T) {
in := `[{"name":"run_command","arguments":{"command":"echo a"}},{"name":"run_command","arguments":{"command":"echo b"}}]`
calls := ExtractAllToolCalls(in)
if len(calls) != 2 {
t.Fatalf("expected 2 tool calls, got %d", len(calls))
}
}