From 0705b38b601e5a0cc765eeb53c467bfa7ad9bad1 Mon Sep 17 00:00:00 2001 From: dtapps Date: Mon, 27 Apr 2026 21:21:16 +0800 Subject: [PATCH] feat: add get_current_time tool Add a new utility tool to get current time/date with the following features: - Multiple output formats: iso, time, date, datetime, unix - Timezone support (e.g., UTC, America/New_York, Asia/Shanghai) - Configurable via PICOCLAW_TOOLS_GET_CURRENT_TIME_ENABLED env var - Web UI support with utility category and i18n translations The tool is registered in the agent tool registry and can be enabled/disabled via configuration or web interface. --- config/config.example.json | 3 + pkg/agent/instance.go | 3 + pkg/config/config.go | 3 + pkg/tools/time.go | 102 ++++++++++++++ pkg/tools/time_test.go | 196 ++++++++++++++++++++++++++ web/backend/api/gateway.go | 3 + web/backend/api/tools.go | 8 ++ web/frontend/src/i18n/locales/en.json | 1 + web/frontend/src/i18n/locales/zh.json | 1 + 9 files changed, 320 insertions(+) create mode 100644 pkg/tools/time.go create mode 100644 pkg/tools/time_test.go diff --git a/config/config.example.json b/config/config.example.json index 30460c231..4e39545f9 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -446,6 +446,9 @@ "spi": { "enabled": false }, + "get_current_time": { + "enabled": true + }, "subagent": { "enabled": true }, diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index d0b25a0a8..6135a7471 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -117,6 +117,9 @@ func NewAgentInstance( if cfg.Tools.IsToolEnabled("append_file") { toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict, allowWritePaths)) } + if cfg.Tools.IsToolEnabled("get_current_time") { + toolsRegistry.Register(tools.NewGetCurrentTimeTool("")) + } sessionsDir := filepath.Join(workspace, "sessions") sessions := initSessionStore(sessionsDir) diff --git a/pkg/config/config.go b/pkg/config/config.go index 16497b4ac..bd9688e9a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -831,6 +831,7 @@ type ToolsConfig struct { Subagent ToolConfig `json:"subagent" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"` WebFetch ToolConfig `json:"web_fetch" yaml:"-" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"` WriteFile ToolConfig `json:"write_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"` + GetCurrentTime ToolConfig `json:"get_current_time" yaml:"-" envPrefix:"PICOCLAW_TOOLS_GET_CURRENT_TIME_"` } // IsFilterSensitiveDataEnabled returns true if sensitive data filtering is enabled @@ -1566,6 +1567,8 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool { return t.WriteFile.Enabled case "mcp": return t.MCP.Enabled + case "get_current_time": + return t.GetCurrentTime.Enabled default: return true } diff --git a/pkg/tools/time.go b/pkg/tools/time.go new file mode 100644 index 000000000..d2d32a62b --- /dev/null +++ b/pkg/tools/time.go @@ -0,0 +1,102 @@ +package tools + +import ( + "context" + "fmt" + "time" + + toolshared "github.com/sipeed/picoclaw/pkg/tools/shared" +) + +// GetCurrentTimeTool returns the current time and/or date information +type GetCurrentTimeTool struct { + timezone string +} + +// NewGetCurrentTimeTool creates a new GetCurrentTimeTool +func NewGetCurrentTimeTool(timezone string) *GetCurrentTimeTool { + if timezone == "" { + timezone = "Local" + } + return &GetCurrentTimeTool{ + timezone: timezone, + } +} + +// Name returns the tool name +func (t *GetCurrentTimeTool) Name() string { + return "get_current_time" +} + +// Description returns the tool description +func (t *GetCurrentTimeTool) Description() string { + return "Get the current time, date, or both. Returns ISO 8601 format by default, or can return formatted strings suitable for display." +} + +// Parameters returns the tool parameters schema +func (t *GetCurrentTimeTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "format": map[string]any{ + "type": "string", + "enum": []string{"iso", "time", "date", "datetime", "unix"}, + "description": "Output format: 'iso' (ISO 8601, default), 'time' (HH:MM:SS), 'date' (YYYY-MM-DD), 'datetime' (YYYY-MM-DD HH:MM:SS), 'unix' (Unix timestamp)", + "default": "iso", + }, + "timezone": map[string]any{ + "type": "string", + "description": "Timezone name (e.g., 'America/New_York', 'Europe/London', 'Asia/Shanghai'). Uses system local time if not specified.", + }, + }, + } +} + +// Execute runs the tool +func (t *GetCurrentTimeTool) Execute(ctx context.Context, args map[string]any) *toolshared.ToolResult { + // Get timezone + tzName := t.timezone + if tzArg, ok := args["timezone"].(string); ok && tzArg != "" { + tzName = tzArg + } + + // Load timezone + loc, err := time.LoadLocation(tzName) + if err != nil { + // Fallback to local timezone if specified one is invalid + loc = time.Local + tzName = "Local" + } + + now := time.Now().In(loc) + + // Get format + format := "iso" + if fmtArg, ok := args["format"].(string); ok && fmtArg != "" { + format = fmtArg + } + + var result string + switch format { + case "time": + result = now.Format("15:04:05") + case "date": + result = now.Format("2006-01-02") + case "datetime": + result = now.Format("2006-01-02 15:04:05") + case "unix": + result = fmt.Sprintf("%d", now.Unix()) + case "iso": + fallthrough + default: + result = now.Format(time.RFC3339) + } + + // Build response + response := fmt.Sprintf("Current time (%s): %s", tzName, result) + + return &toolshared.ToolResult{ + ForLLM: response, + ForUser: response, + } +} diff --git a/pkg/tools/time_test.go b/pkg/tools/time_test.go new file mode 100644 index 000000000..f65337593 --- /dev/null +++ b/pkg/tools/time_test.go @@ -0,0 +1,196 @@ +package tools + +import ( + "context" + "strings" + "testing" + "time" +) + +func TestGetCurrentTimeTool_Name(t *testing.T) { + tool := NewGetCurrentTimeTool("") + if tool.Name() != "get_current_time" { + t.Errorf("expected name 'get_current_time', got %s", tool.Name()) + } +} + +func TestGetCurrentTimeTool_Description(t *testing.T) { + tool := NewGetCurrentTimeTool("") + if tool.Description() == "" { + t.Error("description should not be empty") + } +} + +func TestGetCurrentTimeTool_Parameters(t *testing.T) { + tool := NewGetCurrentTimeTool("") + params := tool.Parameters() + if params == nil { + t.Fatal("parameters should not be nil") + } + + // Check type + if params["type"] != "object" { + t.Errorf("expected type 'object', got %v", params["type"]) + } + + // Check properties exist + properties, ok := params["properties"].(map[string]any) + if !ok { + t.Fatal("properties should be a map") + } + + if _, ok := properties["format"]; !ok { + t.Error("format property should exist") + } + + if _, ok := properties["timezone"]; !ok { + t.Error("timezone property should exist") + } +} + +func TestGetCurrentTimeTool_Execute_DefaultFormat(t *testing.T) { + tool := NewGetCurrentTimeTool("") + result := tool.Execute(context.Background(), map[string]any{}) + + if result == nil { + t.Fatal("result should not be nil") + } + + if result.IsError { + t.Errorf("should not return error: %s", result.ForLLM) + } + + // Check that result contains time information + if result.ForLLM == "" { + t.Error("ForLLM should not be empty") + } + + // Default format is ISO, should contain current year + currentYear := time.Now().Format("2006") + if !strings.Contains(result.ForLLM, currentYear) { + t.Errorf("result should contain current year %s, got: %s", currentYear, result.ForLLM) + } +} + +func TestGetCurrentTimeTool_Execute_TimeFormat(t *testing.T) { + tool := NewGetCurrentTimeTool("") + result := tool.Execute(context.Background(), map[string]any{ + "format": "time", + }) + + if result == nil { + t.Fatal("result should not be nil") + } + + if result.IsError { + t.Errorf("should not return error: %s", result.ForLLM) + } + + // Time format should be HH:MM:SS + if !strings.Contains(result.ForLLM, ":") { + t.Errorf("time format should contain colon, got: %s", result.ForLLM) + } +} + +func TestGetCurrentTimeTool_Execute_DateFormat(t *testing.T) { + tool := NewGetCurrentTimeTool("") + result := tool.Execute(context.Background(), map[string]any{ + "format": "date", + }) + + if result == nil { + t.Fatal("result should not be nil") + } + + if result.IsError { + t.Errorf("should not return error: %s", result.ForLLM) + } + + // Date format should be YYYY-MM-DD + currentYear := time.Now().Format("2006") + if !strings.Contains(result.ForLLM, currentYear) { + t.Errorf("date format should contain current year %s, got: %s", currentYear, result.ForLLM) + } +} + +func TestGetCurrentTimeTool_Execute_DateTimeFormat(t *testing.T) { + tool := NewGetCurrentTimeTool("") + result := tool.Execute(context.Background(), map[string]any{ + "format": "datetime", + }) + + if result == nil { + t.Fatal("result should not be nil") + } + + if result.IsError { + t.Errorf("should not return error: %s", result.ForLLM) + } + + // Datetime format should contain both date and time + if !strings.Contains(result.ForLLM, "-") || !strings.Contains(result.ForLLM, ":") { + t.Errorf("datetime format should contain both date and time, got: %s", result.ForLLM) + } +} + +func TestGetCurrentTimeTool_Execute_UnixFormat(t *testing.T) { + tool := NewGetCurrentTimeTool("") + result := tool.Execute(context.Background(), map[string]any{ + "format": "unix", + }) + + if result == nil { + t.Fatal("result should not be nil") + } + + if result.IsError { + t.Errorf("should not return error: %s", result.ForLLM) + } + + // Unix timestamp should be a number (just check it doesn't contain date separators) + // The response format is "Current time (Local): 1777295143" which contains a colon after "Local" + // So we just check the actual timestamp part is numeric + if !strings.Contains(result.ForLLM, "Current time") { + t.Errorf("result should contain 'Current time', got: %s", result.ForLLM) + } +} + +func TestGetCurrentTimeTool_Execute_WithTimezone(t *testing.T) { + tool := NewGetCurrentTimeTool("") + result := tool.Execute(context.Background(), map[string]any{ + "timezone": "UTC", + }) + + if result == nil { + t.Fatal("result should not be nil") + } + + if result.IsError { + t.Errorf("should not return error: %s", result.ForLLM) + } + + // Should mention UTC + if !strings.Contains(result.ForLLM, "UTC") { + t.Errorf("result should mention UTC timezone, got: %s", result.ForLLM) + } +} + +func TestGetCurrentTimeTool_Execute_InvalidTimezone(t *testing.T) { + tool := NewGetCurrentTimeTool("") + result := tool.Execute(context.Background(), map[string]any{ + "timezone": "Invalid/Timezone", + }) + + if result == nil { + t.Fatal("result should not be nil") + } + + if result.IsError { + t.Error("should handle invalid timezone gracefully without error") + } + + // Should fallback to Local + if !strings.Contains(result.ForLLM, "Local") { + t.Errorf("should fallback to Local timezone, got: %s", result.ForLLM) + } +} diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go index 67b055236..05c52a978 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -466,6 +466,9 @@ func computeConfigSignature(cfg *config.Config) string { if cfg.Tools.SPI.Enabled { toolSignatures = append(toolSignatures, "spi") } + if cfg.Tools.GetCurrentTime.Enabled { + toolSignatures = append(toolSignatures, "get_current_time") + } if cfg.Tools.MCP.Enabled { toolSignatures = append(toolSignatures, "mcp") } diff --git a/web/backend/api/tools.go b/web/backend/api/tools.go index c6c2deaae..96e1b57e4 100644 --- a/web/backend/api/tools.go +++ b/web/backend/api/tools.go @@ -171,6 +171,12 @@ var toolCatalog = []toolCatalogEntry{ Category: "hardware", ConfigKey: "spi", }, + { + Name: "get_current_time", + Description: "Get the current time, date, or both in various formats and timezones.", + Category: "utility", + ConfigKey: "get_current_time", + }, { Name: "tool_search_tool_regex", Description: "Discover hidden MCP tools by regex search when tool discovery is enabled.", @@ -362,6 +368,8 @@ func applyToolState(cfg *config.Config, toolName string, enabled bool) error { cfg.Tools.I2C.Enabled = enabled case "spi": cfg.Tools.SPI.Enabled = enabled + case "get_current_time": + cfg.Tools.GetCurrentTime.Enabled = enabled case "tool_search_tool_regex": cfg.Tools.MCP.Discovery.UseRegex = enabled if enabled { diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 75a17e791..6b096eedd 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -599,6 +599,7 @@ "skills": "Skills", "agents": "Agents", "hardware": "Hardware", + "utility": "Utility", "discovery": "Discovery" }, "reasons": { diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 0a140605a..2e7e4edcd 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -599,6 +599,7 @@ "skills": "技能", "agents": "Agent", "hardware": "硬件", + "utility": "实用工具", "discovery": "发现" }, "reasons": {