diff --git a/cmd/gateway.go b/cmd/gateway.go index e1a96b53..9e95800c 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -165,6 +165,9 @@ func runGateway() { skillsLoader, skillSearchTool, globalSkillsDir := setupSkillsSystem(cfg, workspace, dataDir, pgStores, toolsReg, providerRegistry, msgBus) _ = skillSearchTool // used via wireExtras → skillsLoader; kept for type clarity + // DateTime tool (precise time for cron scheduling, memory timestamps, etc.) + toolsReg.Register(tools.NewDateTimeTool()) + // Cron tool (agent-facing, matching TS cron-tool.ts) toolsReg.Register(tools.NewCronTool(pgStores.Cron)) slog.Info("cron tool registered") diff --git a/internal/agent/systemprompt.go b/internal/agent/systemprompt.go index 5737edf8..a19234b8 100644 --- a/internal/agent/systemprompt.go +++ b/internal/agent/systemprompt.go @@ -67,6 +67,7 @@ var coreToolSummaries = map[string]string{ "spawn": "Spawn a self-clone subagent to handle a task in the background", "web_search": "Search the web", "web_fetch": "Fetch and extract content from a URL", + "datetime": "Get current date/time with timezone support — use before creating cron jobs or time-sensitive operations", "cron": "Manage scheduled jobs and reminders", "skill_search": "Search available skills by keyword (weather, translate, github, etc.)", "use_skill": "Invoke a skill by name and follow its instructions", diff --git a/internal/agent/systemprompt_sections.go b/internal/agent/systemprompt_sections.go index 76b99561..f029db9f 100644 --- a/internal/agent/systemprompt_sections.go +++ b/internal/agent/systemprompt_sections.go @@ -86,7 +86,7 @@ func buildUserIdentitySection(ownerIDs []string) []string { func buildTimeSection() []string { now := time.Now() return []string{ - fmt.Sprintf("Current time: %s (UTC)", now.UTC().Format("2006-01-02 15:04 Monday")), + fmt.Sprintf("Current date: %s (UTC)", now.UTC().Format("2006-01-02 Monday")), "", } } diff --git a/internal/providers/anthropic_request.go b/internal/providers/anthropic_request.go index f8259ed8..67aa3692 100644 --- a/internal/providers/anthropic_request.go +++ b/internal/providers/anthropic_request.go @@ -145,11 +145,15 @@ func (p *AnthropicProvider) buildRequestBody(model string, req ChatRequest, stre } } + // Add cache_control breakpoint to the last system block (caches system prompt prefix). + if len(systemBlocks) > 0 { + systemBlocks[len(systemBlocks)-1]["cache_control"] = map[string]any{"type": "ephemeral"} + } + body := map[string]any{ - "model": model, - "max_tokens": 4096, - "messages": messages, - "cache_control": map[string]any{"type": "ephemeral"}, + "model": model, + "max_tokens": 4096, + "messages": messages, } if stream { @@ -172,6 +176,10 @@ func (p *AnthropicProvider) buildRequestBody(model string, req ChatRequest, stre } tools = append(tools, tool) } + // Add cache_control breakpoint to the last tool (caches tool definitions prefix). + if len(tools) > 0 { + tools[len(tools)-1]["cache_control"] = map[string]any{"type": "ephemeral"} + } body["tools"] = tools } diff --git a/internal/tools/cron.go b/internal/tools/cron.go index 7247a44f..3682a801 100644 --- a/internal/tools/cron.go +++ b/internal/tools/cron.go @@ -64,6 +64,7 @@ CRITICAL CONSTRAINTS: - message is required for add action - schedule is required for add action - Default: jobs run as isolated agent turns with the specified message +- Before creating or updating a scheduled job, call the datetime tool first to get the precise current time and unix_ms timestamp. Do NOT guess or estimate timestamps. Use jobId as the canonical identifier; id is accepted for compatibility.` } @@ -309,6 +310,13 @@ func (t *CronTool) handleUpdate(args map[string]any, agentID, userID string) *Re patchJSON, _ := json.Marshal(patchObj) json.Unmarshal(patchJSON, &patch) + // Validate atMs not in the past when updating schedule + if patch.Schedule != nil && patch.Schedule.Kind == "at" && patch.Schedule.AtMS != nil { + if *patch.Schedule.AtMS <= time.Now().UnixMilli() { + return ErrorResult(fmt.Sprintf("schedule.atMs is in the past (%d). Use the datetime tool to get current time, then set a future timestamp. Current time is %d ms", *patch.Schedule.AtMS, time.Now().UnixMilli())) + } + } + job, err := t.cronStore.UpdateJob(jobID, patch) if err != nil { return ErrorResult(fmt.Sprintf("failed to update cron job: %v", err)) diff --git a/internal/tools/datetime.go b/internal/tools/datetime.go new file mode 100644 index 00000000..25f63d2e --- /dev/null +++ b/internal/tools/datetime.go @@ -0,0 +1,56 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "time" +) + +// DateTimeTool provides precise current date/time with timezone support. +// Models use this before creating cron jobs or any time-sensitive operation +// instead of guessing timestamps from the system prompt's date-only field. +type DateTimeTool struct{} + +func NewDateTimeTool() *DateTimeTool { return &DateTimeTool{} } + +func (t *DateTimeTool) Name() string { return "datetime" } + +func (t *DateTimeTool) Description() string { + return `Get the current date and time. Use this when you need precise timestamps for scheduling (cron jobs), logging, or any time-sensitive operation. + +Returns current time in both UTC and the requested timezone. +If no timezone is provided, returns UTC only.` +} + +func (t *DateTimeTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "timezone": map[string]any{ + "type": "string", + "description": "IANA timezone name (e.g. 'Asia/Ho_Chi_Minh', 'America/New_York'). If omitted, returns UTC only.", + }, + }, + } +} + +func (t *DateTimeTool) Execute(_ context.Context, args map[string]any) *Result { + now := time.Now() + result := map[string]any{ + "utc": now.UTC().Format(time.RFC3339), + "unix_ms": now.UnixMilli(), + } + + if tz, ok := args["timezone"].(string); ok && tz != "" { + loc, err := time.LoadLocation(tz) + if err != nil { + return ErrorResult(fmt.Sprintf("invalid timezone '%s': use IANA names like 'Asia/Ho_Chi_Minh', 'America/New_York'", tz)) + } + result["local"] = now.In(loc).Format(time.RFC3339) + result["timezone"] = tz + } + + data, _ := json.MarshalIndent(result, "", " ") + return NewResult(string(data)) +}