feat: add datetime toolset

Resolves the browser's IANA timezone via Intl on every call in WASM
builds, since time.Local there caches a fixed offset with no DST
support, falling back to the per-instant JS offset when Intl isn't
available. Native builds are unchanged.

Assisted-By: Docker Agent
Signed-off-by: David Gageot <david.gageot@docker.com>
This commit is contained in:
David Gageot committed 2026-10-08 08:46:41 +02:00
1 parent 6079900923
commit 95ae304d50
22 files changed
+692 -11

No files matched your search

+1 -1
View File
@@ -133,7 +133,7 @@ tasks:
- GOOS=js GOARCH=wasm go test -exec="$(go env GOROOT)/lib/wasm/go_js_wasm_exec" ./cmd/wasm
- GOOS=js GOARCH=wasm go test -exec="$(go env GOROOT)/lib/wasm/go_js_wasm_exec" pkg/model/provider/factory_js_openai_vendor_test.go
- GOOS=js GOARCH=wasm go test -exec="$(go env GOROOT)/lib/wasm/go_js_wasm_exec" -run '^TestLimitLargeToolResults|^TestEnvironmentInfo' ./pkg/hooks/builtins
- GOOS=js GOARCH=wasm go test -exec="$(go env GOROOT)/lib/wasm/go_js_wasm_exec" ./pkg/embeddedchat ./pkg/fuzzy/... ./pkg/tools/builtin/calculator ./pkg/tools/builtin/deferred ./pkg/tools/builtin/think ./pkg/tools/builtin/random ./pkg/tools/builtin/todo ./pkg/tools/builtin/memory ./pkg/memory/database/inmemory ./pkg/tools/codemode ./pkg/tools/toon ./pkg/rag ./pkg/rag/strategy
- GOOS=js GOARCH=wasm go test -exec="$(go env GOROOT)/lib/wasm/go_js_wasm_exec" ./pkg/embeddedchat ./pkg/fuzzy/... ./pkg/tools/builtin/calculator ./pkg/tools/builtin/datetime ./pkg/tools/builtin/deferred ./pkg/tools/builtin/think ./pkg/tools/builtin/random ./pkg/tools/builtin/todo ./pkg/tools/builtin/memory ./pkg/memory/database/inmemory ./pkg/tools/codemode ./pkg/tools/toon ./pkg/rag ./pkg/rag/strategy
- GOOS=js GOARCH=wasm go test -exec="$(go env GOROOT)/lib/wasm/go_js_wasm_exec" -run 'MemoryStorage|Conformance' ./pkg/tools/builtin/plan
- GOOS=js GOARCH=wasm go test -exec="$(go env GOROOT)/lib/wasm/go_js_wasm_exec" -run 'Egress|TestWithOAuthTokenStore' ./pkg/httpclient ./pkg/tools/mcp
- GOOS=js GOARCH=wasm go test -tags docker_agent_wasm_cloud -exec="$(go env GOROOT)/lib/wasm/go_js_wasm_exec" ./cmd/wasm ./pkg/model/provider/bedrock ./pkg/model/provider/gemini ./pkg/model/provider/anthropic/vertex ./pkg/model/provider/vertexai
+4 -2
View File
@@ -2330,7 +2330,8 @@
"rag",
"git",
"webhook",
"environment"
"environment",
"datetime"
]
},
"instruction": {
@@ -2695,7 +2696,8 @@
"model_picker",
"background_agents",
"scheduler",
"git"
"git",
"datetime"
]
}
}
+1 -1
View File
@@ -303,7 +303,7 @@ What the browser build supports, and what it refuses and why.
| Area | In the browser |
| --- | --- |
| Toolsets | `mcp` (remote only), [`calculator`](../../docs/tools/calculator/index.md), `think`, `random`, `todo`, `plan`, `memory`, `user_prompt`, `session_context`, `fetch`, `api`, `openapi`, `model_picker`, `rag` (over `documents`). See `examples/portable-team.yaml` and `examples/handbook-rag.yaml`. |
| Toolsets | `datetime`, `mcp` (remote only), [`calculator`](../../docs/tools/calculator/index.md), `think`, `random`, `todo`, `plan`, `memory`, `user_prompt`, `session_context`, `fetch`, `api`, `openapi`, `model_picker`, `rag` (over `documents`). See `examples/portable-team.yaml` and `examples/handbook-rag.yaml`. |
| Toolset options | `tools`, `readonly`, `instruction`, `model`, `toon`, `defer`, `timeout`, `allow_private_ips`. |
| Agent features | `code_mode_tools`, sub-agents, handoffs, fallbacks, compaction, `add_date`, `add_environment_info`, structured output, `${...}` JavaScript in instructions and descriptions. |
| Hooks | `type: builtin` only: `add_context`, `add_date`, `add_environment_info`, `limit_large_tool_results`, `max_iterations`, `redact_secrets`. `limit_large_tool_results` keeps only the bounded tail excerpt: there is no filesystem to spill the full result to, and the notice says so. |
+2
View File
@@ -21,6 +21,7 @@ import (
"github.com/docker/docker-agent/pkg/tools"
"github.com/docker/docker-agent/pkg/tools/builtin/api/client"
"github.com/docker/docker-agent/pkg/tools/builtin/calculator"
"github.com/docker/docker-agent/pkg/tools/builtin/datetime"
"github.com/docker/docker-agent/pkg/tools/builtin/fetch"
"github.com/docker/docker-agent/pkg/tools/builtin/memory"
"github.com/docker/docker-agent/pkg/tools/builtin/modelpicker"
@@ -59,6 +60,7 @@ func browserToolsetCreators(documents rag.Documents) map[string]teamloader.Tools
memories := sync.OnceValue(inmemory.New)
return map[string]teamloader.ToolsetCreator{
"datetime": teamloader.Creator(datetime.CreateToolSet),
"mcp": mcpCreator,
"calculator": teamloader.Creator(calculator.CreateToolSet),
"think": teamloader.Creator(think.CreateToolSet),
+19 -5
View File
@@ -12,6 +12,7 @@ import (
"strconv"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -63,7 +64,7 @@ func callTool(t *testing.T, ts tools.ToolSet, name, args string) string {
func TestBrowserToolsetsServePortableBuiltins(t *testing.T) {
registry := browserToolsets(nil)
for _, supported := range []string{"mcp", "calculator", "think", "random", "todo", "plan", "memory", "user_prompt", "session_context", "fetch", "api", "openapi", "model_picker", "rag"} {
for _, supported := range []string{"datetime", "mcp", "calculator", "think", "random", "todo", "plan", "memory", "user_prompt", "session_context", "fetch", "api", "openapi", "model_picker", "rag"} {
assert.True(t, registry.Has(supported), supported)
}
for _, unsupported := range []string{"shell", "script", "filesystem", "file", "git", "tasks", "environment", "background_jobs", "background_agents", "lsp", "mcp_catalog", "a2a", "webhook", "open_url", "scheduler"} {
@@ -100,6 +101,7 @@ func TestRepeatableToolsRunInASession(t *testing.T) {
args string
}{
{toolset: "random", tool: "random_int", args: `{"min":1,"max":6}`},
{toolset: "datetime", tool: "get_datetime", args: `{"format":"2006-01-02T15:04:05Z07:00","timezone":"UTC"}`},
} {
t.Run(fmt.Sprintf("%s/code_mode=%t", tc.toolset, codeMode), func(t *testing.T) {
yaml := fmt.Sprintf(`
@@ -128,16 +130,28 @@ agents:
for _, result := range results {
assert.Equal(t, false, result["is_error"])
output := result["output"].(string)
value, err := strconv.ParseInt(output, 10, 64)
require.NoError(t, err)
assert.GreaterOrEqual(t, value, int64(1))
assert.LessOrEqual(t, value, int64(6))
if tc.toolset == "random" {
value, err := strconv.ParseInt(output, 10, 64)
require.NoError(t, err)
assert.GreaterOrEqual(t, value, int64(1))
assert.LessOrEqual(t, value, int64(6))
} else {
_, err := time.Parse(time.RFC3339, output)
require.NoError(t, err)
}
}
})
}
}
}
func TestDatetimeRunsInBrowser(t *testing.T) {
ts := createTool(t, browserToolsets(nil), latest.Toolset{Type: "datetime"})
assert.Equal(t, []string{"get_datetime"}, toolNames(t, ts))
output := callTool(t, ts, "get_datetime", `{"format":"2006-01-02T15:04:05Z07:00","timezone":"Asia/Tokyo"}`)
assert.Regexp(t, `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+09:00$`, output)
}
func TestRAGRejectsWhatDocumentsCannotHonour(t *testing.T) {
for name, tc := range map[string]struct {
strategy string
+1
View File
@@ -24,6 +24,7 @@ Built-in tools are included with Docker Agent and require no external dependenci
| `background_jobs` | Run and manage long-running shell commands | [Background Jobs](../../tools/background-jobs/index.md) |
| `scheduler` | Schedule instructions to run at a time or on a recurring interval | [Scheduler](../../tools/scheduler/index.md) |
| `environment` | Report the OS and resolved shell (read-only, no arguments, auto-approved) | [Environment](../../tools/environment/index.md) |
| `datetime` | Current date and time in a model-chosen format and timezone (read-only) | [Date and Time](../../tools/datetime/index.md) |
| `calculator` | Exact arithmetic without code execution | [Calculator](../../tools/calculator/index.md) |
| `think` | Reasoning scratchpad | [Think](../../tools/think/index.md) |
| `random` | Random integers with agent-chosen inclusive bounds | [Random](../../tools/random/index.md) |
+2
View File
@@ -69,6 +69,8 @@
url: /tools/shell/
- title: Environment
url: /tools/environment/
- title: Date and Time
url: /tools/datetime/
- title: Script
url: /tools/script/
- title: Open URL
+1 -1
View File
@@ -73,7 +73,7 @@ It is not a general-purpose replacement for direct tool calls: for an agent that
- **One string result.** The script must return a string; use `console.*` inside the script to print debug information if something doesn't behave as expected — it comes back to the model as `stdout`/`stderr` alongside the result, but is not displayed by the terminal UIs.
- **Failures are diagnosable.** If the script throws or returns unexpectedly, the response includes the tool calls it made before failing (name, arguments, and result or error), so the model can see what happened and adjust the script on the next attempt.
- **Not every tool is wrapped.** Tools in the `todo` category and repeatable tools such as `random_int` stay directly callable as ordinary tools. Repeatable tools are unavailable inside scripts so successful calls retain their duplicate-loop exemption without exempting failed or unrelated scripts.
- **Not every tool is wrapped.** Tools in the `todo` category and repeatable tools such as `random_int` and `get_datetime` stay directly callable as ordinary tools. Repeatable tools are unavailable inside scripts so successful calls retain their duplicate-loop exemption without exempting failed or unrelated scripts.
- **The script runs in an embedded, sandboxed JS engine** ([goja](https://github.com/dop251/goja)), not Node.js or a browser: there is no filesystem, network, or process access beyond the tool functions injected into it.
- **Partial startup is supported.** If one toolset fails to initialize (for example, an MCP server that won't connect), Code Mode remains available with the successfully loaded toolsets. The failed toolset is omitted from the JavaScript environment and retried on subsequent turns. A warning is emitted once when the failure first occurs, but not on subsequent turns while the failure persists. When the toolset recovers, its tools silently reappear in the environment. When the failed toolset's cause is retryable (e.g. an MCP server or RAG knowledge base hitting rate limits), its retries are paced by the same [backoff gate](../../tools/mcp/index.md#lifecycle-auto-restart-profiles) used outside code mode, instead of retrying on every turn.
+66
View File
@@ -0,0 +1,66 @@
---
title: "Date and Time Tool"
description: "Obtain the current date, time, or datetime in a model-chosen format."
keywords: docker agent, ai agents, tools, toolsets, datetime, date, time
linkTitle: "Date and Time"
weight: 146
canonical: https://docs.docker.com/ai/docker-agent/tools/datetime/
---
_Obtain the current date, time, or datetime in a model-chosen format._
## Overview
The `datetime` toolset exposes one read-only tool, `get_datetime`. It reads the
current clock on each call and returns only the formatted string. No shell or
external service is needed. It works in native and browser builds.
## Configuration
```yaml
agents:
root:
model: openai/gpt-5-mini
instruction: Use get_datetime for the current date or time.
toolsets:
- type: datetime
```
No toolset-specific configuration is required. The model chooses the format and
optional timezone in each tool call.
## Tool arguments
| Argument | Required | Description |
| --- | --- | --- |
| `format` | Yes | A Go time layout based on the reference time `Mon Jan 2 15:04:05 MST 2006`. |
| `timezone` | No | An IANA timezone such as `Europe/Paris`, `America/New_York`, or `UTC`. Defaults to the host's local timezone; `Local` also selects it. |
Choose a layout containing only the parts needed:
| Desired output | `format` | Example result |
| --- | --- | --- |
| Date | `2006-01-02` | `2026-10-07` |
| Time | `15:04:05` | `23:04:05` |
| 12-hour time | `3:04 PM` | `11:04 PM` |
| RFC3339 datetime | `2006-01-02T15:04:05Z07:00` | `2026-10-07T23:04:05Z` |
| Long date | `Monday, January 2, 2006` | `Wednesday, October 7, 2026` |
For example:
```json
{"format":"2006-01-02T15:04:05Z07:00","timezone":"UTC"}
```
The result is plain text, for example `2026-10-07T23:04:05Z`, with no label or
JSON wrapper.
Use Go layouts, not `strftime` directives (`%Y-%m-%d`) or tokens such as
`YYYY-MM-DD`. Literal text in the layout is preserved. See the
[Go time layout documentation](https://pkg.go.dev/time#Layout) for more formats.
Empty or whitespace-only formats and unknown timezones return tool errors.
The tool has no side effects and carries `ReadOnlyHint`. Approval still depends
on the session's [safety mode and permission rules](../../configuration/permissions/index.md).
See [the example configuration](https://github.com/docker/docker-agent/blob/main/examples/datetime.yaml).
+2 -1
View File
@@ -80,12 +80,13 @@ Examples that wire up one of the toolsets shipped with docker-agent
| [`sandbox_agent.yaml`](sandbox_agent.yaml) | Same shell-based agent run with `--sandbox` to confine commands to a Docker container. |
| [`deferred.yaml`](deferred.yaml) | Wraps the agent in a deferred-execution container (`runtime: deferred`). |
### Calculator and random choices
### Calculator, random choices, and date/time
| File | What it shows |
|------|---------------|
| [`calculator.yaml`](calculator.yaml) | Exact arithmetic with decimals, fractions, and integer powers, without code execution. |
| [`random.yaml`](random.yaml) | Random integers with agent-chosen inclusive bounds for dice rolls and random choices. |
| [`datetime.yaml`](datetime.yaml) | Current date and time in a chosen Go layout and timezone. |
### Memory, todo, think, cache
+13
View File
@@ -0,0 +1,13 @@
agents:
root:
model: openai/gpt-5-mini
description: Reports the current date and time in your preferred format
instruction: |
Use get_datetime whenever the user asks for the current date or time.
Choose a Go time layout that matches the requested format, such as
2006-01-02 for a date, 15:04:05 for a time, or
2006-01-02T15:04:05Z07:00 for a full datetime.
Set timezone when the user requests a particular location or UTC;
otherwise use the host's local timezone. Return the result concisely.
toolsets:
- type: datetime
+53
View File
@@ -0,0 +1,53 @@
package config
import (
"os"
"testing"
"github.com/goccy/go-yaml"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/xeipuuv/gojsonschema"
)
func TestDatetimeToolsetConfig(t *testing.T) {
t.Parallel()
schemaBytes, err := os.ReadFile(schemaFile)
require.NoError(t, err)
schema, err := gojsonschema.NewSchema(gojsonschema.NewBytesLoader(schemaBytes))
require.NoError(t, err)
configs := map[string]string{
"inline": `agents:
root:
model: openai/gpt-5-mini
toolsets:
- type: datetime
`,
"named": `toolsets:
clock:
type: datetime
agents:
root:
model: openai/gpt-5-mini
use_toolsets: [clock]
`,
}
for name, data := range configs {
t.Run(name, func(t *testing.T) {
t.Parallel()
var raw any
require.NoError(t, yaml.Unmarshal([]byte(data), &raw))
result, err := schema.Validate(gojsonschema.NewRawLoader(raw))
require.NoError(t, err)
assert.True(t, result.Valid(), "%v", result.Errors())
cfg, err := Load(t.Context(), NewBytesSource("datetime.yaml", []byte(data)))
require.NoError(t, err)
require.Len(t, cfg.Agents, 1)
require.Len(t, cfg.Agents[0].Toolsets, 1)
assert.Equal(t, "datetime", cfg.Agents[0].Toolsets[0].Type)
})
}
}
+93
View File
@@ -0,0 +1,93 @@
package runtime
import (
"fmt"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/docker/docker-agent/pkg/agent"
"github.com/docker/docker-agent/pkg/chat"
"github.com/docker/docker-agent/pkg/session"
"github.com/docker/docker-agent/pkg/team"
"github.com/docker/docker-agent/pkg/tools"
"github.com/docker/docker-agent/pkg/tools/builtin/datetime"
"github.com/docker/docker-agent/pkg/tools/codemode"
)
func TestRunStream_RepeatedDatetimeReads(t *testing.T) {
t.Parallel()
for _, codeMode := range []bool{false, true} {
for _, tc := range []struct {
name string
args string
failed bool
denied bool
}{
{name: "valid", args: `{"format":"2006-01-02T15:04:05.999999999Z07:00","timezone":"UTC"}`},
{name: "missing format", args: `{}`, failed: true},
{name: "invalid timezone", args: `{"format":"2006-01-02","timezone":"Not/A_Zone"}`, failed: true},
{name: "malformed arguments", args: `{"format":`, failed: true},
{name: "denied", args: `{"format":"2006-01-02"}`, failed: true, denied: true},
} {
t.Run(fmt.Sprintf("%s/code_mode=%t", tc.name, codeMode), func(t *testing.T) {
t.Parallel()
var streams []chat.MessageStream
for i := range 6 {
id := fmt.Sprintf("clock_%d", i)
streams = append(streams, newStreamBuilder().
AddToolCallName(id, datetime.ToolNameGetDatetime).
AddToolCallArguments(id, tc.args).
AddToolCallStopWithUsage(2, 2).Build())
}
streams = append(streams, newStreamBuilder().AddContent("checked six times").AddStopWithUsage(2, 2).Build())
prov := &queueProvider{id: "test/mock-model", streams: streams}
ts := tools.ToolSet(datetime.New())
if codeMode {
ts = codemode.Wrap(ts)
}
root := agent.New("root", "clock", agent.WithModel(prov), agent.WithToolSets(ts))
rt, err := NewLocalRuntime(t.Context(), team.New(team.WithAgents(root)), WithSessionCompaction(false), WithModelStore(mockModelStore{}))
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, rt.Close()) })
sess := session.New(session.WithUserMessage("check the clock"), session.WithToolsApproved(true))
sess.Title = "Unit Test"
if tc.denied {
sess.Permissions = &session.PermissionsConfig{Deny: []string{datetime.ToolNameGetDatetime}}
}
var failures []*ErrorEvent
responses := 0
for event := range rt.RunStream(t.Context(), sess) {
if e, ok := event.(*ErrorEvent); ok {
failures = append(failures, e)
}
if e, ok := event.(*ToolCallResponseEvent); ok {
responses++
require.NotNil(t, e.Result)
assert.Equal(t, tc.failed, e.Result.IsError)
if !tc.failed {
_, err := time.Parse(time.RFC3339Nano, e.Result.Output)
require.NoError(t, err)
}
}
}
if tc.failed {
require.Len(t, failures, 1)
assert.Equal(t, ErrorCodeLoopDetected, failures[0].Code)
assert.Equal(t, 5, responses)
assert.Len(t, prov.streams, 2)
} else {
assert.Empty(t, failures)
assert.Equal(t, 6, responses)
assert.Equal(t, "checked six times", sess.GetLastAssistantMessageContent())
assert.Empty(t, prov.streams)
}
})
}
}
}
+1
View File
@@ -14,6 +14,7 @@ var BuiltinToolsets = []BuiltinToolsetInfo{
builtinToolset("background_agents", "background-agents", "Dispatch work to sub-agents concurrently and collect results"),
builtinToolset("background_jobs", "background-jobs", "Run and manage long-running shell commands"),
builtinToolset("calculator", "calculator", "Evaluate arithmetic expressions with exact results and no code execution"),
builtinToolset("datetime", "datetime", "Obtain the current date and time in a chosen format and timezone"),
builtinToolset("environment", "environment", "Report the OS and resolved shell (read-only, no arguments)"),
builtinToolset("fetch", "fetch", "Read content from HTTP/HTTPS URLs"),
builtinToolset("file", "file", "Read, write, and edit individual files"),
+35
View File
@@ -0,0 +1,35 @@
package toolsets
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/docker/docker-agent/pkg/config"
"github.com/docker/docker-agent/pkg/config/latest"
"github.com/docker/docker-agent/pkg/tools"
"github.com/docker/docker-agent/pkg/tools/builtin/datetime"
)
func TestDatetimeToolsetLoadsAndRuns(t *testing.T) {
t.Parallel()
registry := NewDefaultToolsetRegistry()
require.True(t, registry.Has("datetime"))
ts, err := registry.CreateTool(t.Context(), latest.Toolset{Type: "datetime"}, "", &config.RuntimeConfig{}, "root")
require.NoError(t, err)
list, err := ts.Tools(t.Context())
require.NoError(t, err)
require.Len(t, list, 1)
assert.Equal(t, datetime.ToolNameGetDatetime, list[0].Name)
result, err := list[0].Handler(t.Context(), tools.ToolCall{Function: tools.FunctionCall{
Name: datetime.ToolNameGetDatetime, Arguments: `{"format":"2006-01-02T15:04:05Z07:00","timezone":"UTC"}`,
}}, tools.NopRuntime{})
require.NoError(t, err)
require.False(t, result.IsError)
_, err = time.Parse(time.RFC3339, result.Output)
require.NoError(t, err)
}
+2
View File
@@ -11,6 +11,7 @@ import (
"github.com/docker/docker-agent/pkg/tools/builtin/api"
"github.com/docker/docker-agent/pkg/tools/builtin/backgroundjobs"
"github.com/docker/docker-agent/pkg/tools/builtin/calculator"
"github.com/docker/docker-agent/pkg/tools/builtin/datetime"
"github.com/docker/docker-agent/pkg/tools/builtin/environment"
"github.com/docker/docker-agent/pkg/tools/builtin/fetch"
filetool "github.com/docker/docker-agent/pkg/tools/builtin/file"
@@ -49,6 +50,7 @@ func DefaultToolsetCreators() map[string]teamloader.ToolsetCreator {
"background_agents": teamloader.Creator(agenttool.CreateToolSet),
"background_jobs": backgroundjobs.Creator,
"calculator": teamloader.Creator(calculator.CreateToolSet),
"datetime": teamloader.Creator(datetime.CreateToolSet),
"environment": teamloader.Creator(environment.CreateToolSet),
"fetch": fetch.Creator,
"file": filetool.Creator,
+84
View File
@@ -0,0 +1,84 @@
// Package datetime provides the current date and time in a model-chosen format.
package datetime
import (
"context"
"fmt"
"strings"
"time"
// Keep IANA zones available in minimal containers and browser builds.
_ "time/tzdata"
"github.com/docker/docker-agent/pkg/tools"
)
const ToolNameGetDatetime = "get_datetime"
// CreateToolSet is the registry entry point.
func CreateToolSet() (tools.ToolSet, error) {
return New(), nil
}
type ToolSet struct {
now func() time.Time
}
type Args struct {
Format string `json:"format" jsonschema:"Go time layout using the reference Mon Jan 2 15:04:05 MST 2006. Examples: 2006-01-02 for date, 15:04:05 for time, 2006-01-02T15:04:05Z07:00 for RFC3339 datetime, Monday, January 2, 2006 for a long date. Use Go layouts, not strftime or YYYY-MM-DD tokens."`
Timezone string `json:"timezone,omitempty" jsonschema:"Optional IANA timezone, e.g. Europe/Paris, America/New_York, or UTC. Omit or use Local for the host's local timezone."`
}
func New() *ToolSet {
return &ToolSet{now: time.Now}
}
func (t *ToolSet) get(_ context.Context, args Args) (*tools.ToolCallResult, error) {
if strings.TrimSpace(args.Format) == "" {
return tools.ResultError("Error: format is required; use a Go time layout such as 2006-01-02 or 15:04:05."), nil
}
var location *time.Location
if args.Timezone != "" && args.Timezone != "Local" {
var err error
location, err = time.LoadLocation(args.Timezone)
if err != nil {
return tools.ResultError(fmt.Sprintf("Error: invalid timezone %q: %v", args.Timezone, err)), nil
}
}
now := t.now()
if location == nil {
now = localTime(now)
} else {
now = now.In(location)
}
return tools.ResultSuccess(now.Format(args.Format)), nil
}
func (t *ToolSet) Instructions() string {
return `## Date and Time
Call get_datetime to obtain the current date, time, or datetime rather than guessing.
Choose format using Go's reference time: Mon Jan 2 15:04:05 MST 2006.
Examples: "2006-01-02" (date), "15:04:05" (time), "2006-01-02T15:04:05Z07:00" (datetime).
Do not use strftime directives or YYYY-MM-DD tokens. The result is the formatted string only.
Timezone defaults to the host's local timezone; set timezone to an IANA name or UTC when needed.`
}
func (t *ToolSet) Tools(context.Context) ([]tools.Tool, error) {
return []tools.Tool{
{
Name: ToolNameGetDatetime,
Category: "datetime",
Description: "Obtain the current date, time, or datetime in the chosen Go time layout, optionally in a specific timezone. Returns only the formatted string. Read-only, no side effects.",
Parameters: tools.MustSchemaFor[Args](),
OutputSchema: tools.MustSchemaFor[string](),
Handler: tools.NewHandler(t.get),
AllowRepeatedCalls: true,
Annotations: tools.ToolAnnotations{
ReadOnlyHint: true,
Title: "Date and Time",
},
},
}, nil
}
+140
View File
@@ -0,0 +1,140 @@
package datetime
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/docker/docker-agent/pkg/tools"
)
func TestGetDatetimeFormats(t *testing.T) {
t.Parallel()
now := time.Date(2026, time.October, 7, 23, 4, 5, 123456789, time.UTC)
tests := []struct {
name string
args Args
want string
}{
{name: "date", args: Args{Format: "2006-01-02", Timezone: "UTC"}, want: "2026-10-07"},
{name: "time", args: Args{Format: "15:04:05", Timezone: "UTC"}, want: "23:04:05"},
{name: "datetime", args: Args{Format: time.RFC3339, Timezone: "UTC"}, want: "2026-10-07T23:04:05Z"},
{name: "nanoseconds", args: Args{Format: time.RFC3339Nano, Timezone: "UTC"}, want: "2026-10-07T23:04:05.123456789Z"},
{name: "long date", args: Args{Format: "Monday, January 2, 2006", Timezone: "UTC"}, want: "Wednesday, October 7, 2026"},
{name: "12 hour time", args: Args{Format: "3:04 PM", Timezone: "UTC"}, want: "11:04 PM"},
{name: "daylight saving time", args: Args{Format: time.RFC3339, Timezone: "America/New_York"}, want: "2026-10-07T19:04:05-04:00"},
{name: "next day", args: Args{Format: time.RFC3339, Timezone: "Asia/Tokyo"}, want: "2026-10-08T08:04:05+09:00"},
{name: "fractional offset", args: Args{Format: time.RFC3339, Timezone: "Asia/Kathmandu"}, want: "2026-10-08T04:49:05+05:45"},
{name: "preserve whitespace", args: Args{Format: " 2006-01-02\n", Timezone: "UTC"}, want: " 2026-10-07\n"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ts := &ToolSet{now: func() time.Time { return now }}
result, err := ts.get(t.Context(), tt.args)
require.NoError(t, err)
require.NotNil(t, result)
assert.False(t, result.IsError)
assert.Equal(t, tt.want, result.Output)
})
}
}
func TestGetDatetimeInvalidArguments(t *testing.T) {
t.Parallel()
tests := []struct {
name string
args Args
want string
}{
{name: "missing format", want: "format is required"},
{name: "blank format", args: Args{Format: " \t\n"}, want: "format is required"},
{name: "invalid timezone", args: Args{Format: time.RFC3339, Timezone: "Not/A_Zone"}, want: `invalid timezone "Not/A_Zone"`},
{name: "traversal", args: Args{Format: time.RFC3339, Timezone: "../etc/passwd"}, want: "invalid timezone"},
{name: "absolute path", args: Args{Format: time.RFC3339, Timezone: "/etc/passwd"}, want: "invalid timezone"},
{name: "null byte", args: Args{Format: time.RFC3339, Timezone: "UTC\x00"}, want: "invalid timezone"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result, err := New().get(t.Context(), tt.args)
require.NoError(t, err)
require.NotNil(t, result)
assert.True(t, result.IsError)
assert.Contains(t, result.Output, tt.want)
})
}
}
func TestGetDatetimeReadsClockOnEachCall(t *testing.T) {
t.Parallel()
now := time.Date(2026, time.October, 7, 23, 4, 5, 0, time.UTC)
calls := 0
ts := New()
require.NotNil(t, ts.now)
ts.now = func() time.Time {
calls++
return now
}
for range 2 {
result, err := ts.get(t.Context(), Args{Format: time.RFC3339Nano, Timezone: "UTC"})
require.NoError(t, err)
require.False(t, result.IsError)
assert.Equal(t, now.Format(time.RFC3339Nano), result.Output)
now = now.Add(time.Hour)
}
assert.Equal(t, 2, calls)
}
func TestDatetimeToolDefinitionAndHandler(t *testing.T) {
t.Parallel()
ts, err := CreateToolSet()
require.NoError(t, err)
list, err := ts.Tools(t.Context())
require.NoError(t, err)
require.Len(t, list, 1)
tool := list[0]
assert.Equal(t, ToolNameGetDatetime, tool.Name)
assert.Equal(t, "datetime", tool.Category)
assert.True(t, tool.Annotations.ReadOnlyHint)
assert.True(t, tool.AllowRepeatedCalls)
assert.False(t, tool.Annotations.IdempotentHint)
assert.NotEmpty(t, tools.GetInstructions(ts))
params, err := tools.SchemaToMap(tool.Parameters)
require.NoError(t, err)
assert.Equal(t, "object", params["type"])
assert.Equal(t, []any{"format"}, params["required"])
properties := params["properties"].(map[string]any)
assert.Contains(t, properties, "format")
assert.Contains(t, properties, "timezone")
output, err := tools.SchemaToMap(tool.OutputSchema)
require.NoError(t, err)
assert.Equal(t, "string", output["type"])
result, err := tool.Handler(t.Context(), tools.ToolCall{Function: tools.FunctionCall{
Name: ToolNameGetDatetime, Arguments: `{"format":"2006-01-02","timezone":"UTC"}`,
}}, tools.NopRuntime{})
require.NoError(t, err)
assert.False(t, result.IsError)
assert.Regexp(t, `^\d{4}-\d{2}-\d{2}$`, result.Output)
result, err = tool.Handler(t.Context(), tools.ToolCall{Function: tools.FunctionCall{
Name: ToolNameGetDatetime, Arguments: `{}`,
}}, tools.NopRuntime{})
require.NoError(t, err)
assert.True(t, result.IsError)
_, err = tool.Handler(t.Context(), tools.ToolCall{Function: tools.FunctionCall{
Name: ToolNameGetDatetime, Arguments: `{"format":{}}`,
}}, tools.NopRuntime{})
require.Error(t, err)
}
+36
View File
@@ -0,0 +1,36 @@
//go:build js
package datetime
import (
"syscall/js"
"time"
)
func localTime(now time.Time) time.Time {
// Go's WASM time.Local caches a fixed offset, without DST transitions.
if location := browserLocation(); location != nil {
return now.In(location)
}
offset := js.Global().Get("Date").New(now.UnixMilli()).Call("getTimezoneOffset").Int()
return now.In(time.FixedZone("Local", -offset*60))
}
func browserLocation() (location *time.Location) {
// Intl may be missing or throw in restricted JavaScript hosts.
defer func() {
if recover() != nil {
location = nil
}
}()
intl := js.Global().Get("Intl")
if intl.Type() != js.TypeObject || intl.Get("DateTimeFormat").Type() != js.TypeFunction {
return nil
}
zone := intl.Call("DateTimeFormat").Call("resolvedOptions").Get("timeZone")
if zone.Type() != js.TypeString || zone.String() == "" || zone.String() == "Local" {
return nil
}
location, _ = time.LoadLocation(zone.String())
return location
}
+103
View File
@@ -0,0 +1,103 @@
//go:build js
package datetime
import (
"syscall/js"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetDatetimeBrowserLocalTimezone(t *testing.T) {
zone := "America/New_York"
format := js.FuncOf(func(js.Value, []js.Value) any {
return map[string]any{"timeZone": zone}
})
t.Cleanup(format.Release)
dateTimeFormat := js.FuncOf(func(js.Value, []js.Value) any {
return map[string]any{"resolvedOptions": format}
})
t.Cleanup(dateTimeFormat.Release)
setBrowserIntl(t, js.ValueOf(map[string]any{"DateTimeFormat": dateTimeFormat}))
now := time.Date(2026, time.October, 7, 23, 4, 5, 0, time.UTC)
ts := &ToolSet{now: func() time.Time { return now }}
checkLocal := func(want string) {
t.Helper()
for _, timezone := range []string{"", "Local"} {
result, err := ts.get(t.Context(), Args{Format: time.RFC3339, Timezone: timezone})
require.NoError(t, err)
require.False(t, result.IsError)
assert.Equal(t, want, result.Output)
}
}
checkLocal("2026-10-07T19:04:05-04:00")
now = now.AddDate(0, 1, 0)
checkLocal("2026-11-07T18:04:05-05:00")
zone = "Asia/Tokyo"
checkLocal("2026-11-08T08:04:05+09:00")
}
func TestGetDatetimeBrowserLocalFallback(t *testing.T) {
prototype := js.Global().Get("Date").Get("prototype")
original := prototype.Get("getTimezoneOffset")
offset := js.FuncOf(func(this js.Value, _ []js.Value) any {
if this.Call("getUTCMonth").Int() == 9 {
return 240
}
return 300
})
prototype.Set("getTimezoneOffset", offset)
t.Cleanup(func() {
prototype.Set("getTimezoneOffset", original)
offset.Release()
})
throwing := js.Global().Get("Function").New("throw new Error('Intl unavailable')")
for name, intl := range map[string]js.Value{
"missing Intl": js.Undefined(),
"missing formatter": js.ValueOf(map[string]any{}),
"unknown zone": browserIntlForZone(t, "Unknown/Zone"),
"empty zone": browserIntlForZone(t, ""),
"local zone": browserIntlForZone(t, "Local"),
"throwing formatter": js.ValueOf(map[string]any{"DateTimeFormat": throwing}),
} {
t.Run(name, func(t *testing.T) {
setBrowserIntl(t, intl)
now := time.Date(2026, time.October, 7, 23, 4, 5, 0, time.UTC)
ts := &ToolSet{now: func() time.Time { return now }}
for _, want := range []string{"2026-10-07T19:04:05-04:00", "2026-11-07T18:04:05-05:00"} {
for _, timezone := range []string{"", "Local"} {
result, err := ts.get(t.Context(), Args{Format: time.RFC3339, Timezone: timezone})
require.NoError(t, err)
require.False(t, result.IsError)
assert.Equal(t, want, result.Output)
}
now = now.AddDate(0, 1, 0)
}
})
}
}
func setBrowserIntl(t *testing.T, intl js.Value) {
t.Helper()
original := js.Global().Get("Intl")
js.Global().Set("Intl", intl)
t.Cleanup(func() { js.Global().Set("Intl", original) })
}
func browserIntlForZone(t *testing.T, zone string) js.Value {
t.Helper()
resolvedOptions := js.FuncOf(func(js.Value, []js.Value) any {
return map[string]any{"timeZone": zone}
})
t.Cleanup(resolvedOptions.Release)
dateTimeFormat := js.FuncOf(func(js.Value, []js.Value) any {
return map[string]any{"resolvedOptions": resolvedOptions}
})
t.Cleanup(dateTimeFormat.Release)
return js.ValueOf(map[string]any{"DateTimeFormat": dateTimeFormat})
}
@@ -0,0 +1,9 @@
//go:build !js
package datetime
import "time"
func localTime(now time.Time) time.Time {
return now.In(time.Local)
}
@@ -0,0 +1,24 @@
//go:build !js
package datetime
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetDatetimeLocalTimezone(t *testing.T) {
t.Parallel()
now := time.Date(2026, time.October, 7, 23, 4, 5, 0, time.UTC)
ts := &ToolSet{now: func() time.Time { return now }}
for _, timezone := range []string{"", "Local"} {
result, err := ts.get(t.Context(), Args{Format: time.RFC3339, Timezone: timezone})
require.NoError(t, err)
require.False(t, result.IsError)
assert.Equal(t, now.In(time.Local).Format(time.RFC3339), result.Output)
}
}