mirror of
https://github.com/cheikh2shift/godex.git
synced 2026-10-22 11:02:56 -05:00
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:
1 parent
9c3297ee54
commit
af37a3f7a9
13 files changed
+1243
-169
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, "&", "&")
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
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{})
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user