From 95ae304d502c021d63a9cf73090dd8c6816329d3 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Wed, 7 Oct 2026 21:33:29 +0200 Subject: [PATCH] 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 --- Taskfile.yml | 2 +- agent-schema.json | 6 +- cmd/wasm/README.md | 2 +- cmd/wasm/toolsets.go | 2 + cmd/wasm/toolsets_test.go | 24 ++- docs/configuration/tools/index.md | 1 + docs/data/nav.yml | 2 + docs/features/code-mode/index.md | 2 +- docs/tools/datetime/index.md | 66 +++++++++ examples/README.md | 3 +- examples/datetime.yaml | 13 ++ pkg/config/datetime_test.go | 53 +++++++ pkg/runtime/datetime_test.go | 93 ++++++++++++ pkg/teamloader/toolsets/catalog.go | 1 + pkg/teamloader/toolsets/datetime_test.go | 35 +++++ pkg/teamloader/toolsets/toolsets.go | 2 + pkg/tools/builtin/datetime/datetime.go | 84 +++++++++++ pkg/tools/builtin/datetime/datetime_test.go | 140 ++++++++++++++++++ pkg/tools/builtin/datetime/local_js.go | 36 +++++ pkg/tools/builtin/datetime/local_js_test.go | 103 +++++++++++++ pkg/tools/builtin/datetime/local_native.go | 9 ++ .../builtin/datetime/local_native_test.go | 24 +++ 22 files changed, 692 insertions(+), 11 deletions(-) create mode 100644 docs/tools/datetime/index.md create mode 100644 examples/datetime.yaml create mode 100644 pkg/config/datetime_test.go create mode 100644 pkg/runtime/datetime_test.go create mode 100644 pkg/teamloader/toolsets/datetime_test.go create mode 100644 pkg/tools/builtin/datetime/datetime.go create mode 100644 pkg/tools/builtin/datetime/datetime_test.go create mode 100644 pkg/tools/builtin/datetime/local_js.go create mode 100644 pkg/tools/builtin/datetime/local_js_test.go create mode 100644 pkg/tools/builtin/datetime/local_native.go create mode 100644 pkg/tools/builtin/datetime/local_native_test.go diff --git a/Taskfile.yml b/Taskfile.yml index 1d2e2679e..99d84d0ea 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -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 diff --git a/agent-schema.json b/agent-schema.json index a27c903df..3ae6d7aa3 100644 --- a/agent-schema.json +++ b/agent-schema.json @@ -2330,7 +2330,8 @@ "rag", "git", "webhook", - "environment" + "environment", + "datetime" ] }, "instruction": { @@ -2695,7 +2696,8 @@ "model_picker", "background_agents", "scheduler", - "git" + "git", + "datetime" ] } } diff --git a/cmd/wasm/README.md b/cmd/wasm/README.md index bdb0f1d31..e3940bd9d 100644 --- a/cmd/wasm/README.md +++ b/cmd/wasm/README.md @@ -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. | diff --git a/cmd/wasm/toolsets.go b/cmd/wasm/toolsets.go index 19ba9bada..4f7c67210 100644 --- a/cmd/wasm/toolsets.go +++ b/cmd/wasm/toolsets.go @@ -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), diff --git a/cmd/wasm/toolsets_test.go b/cmd/wasm/toolsets_test.go index 9dec7ffd0..4cdb715c6 100644 --- a/cmd/wasm/toolsets_test.go +++ b/cmd/wasm/toolsets_test.go @@ -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 diff --git a/docs/configuration/tools/index.md b/docs/configuration/tools/index.md index 779369eb4..e11504498 100644 --- a/docs/configuration/tools/index.md +++ b/docs/configuration/tools/index.md @@ -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) | diff --git a/docs/data/nav.yml b/docs/data/nav.yml index db0cc3d2f..fefd45170 100644 --- a/docs/data/nav.yml +++ b/docs/data/nav.yml @@ -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 diff --git a/docs/features/code-mode/index.md b/docs/features/code-mode/index.md index 5f1536019..218e242d9 100644 --- a/docs/features/code-mode/index.md +++ b/docs/features/code-mode/index.md @@ -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. diff --git a/docs/tools/datetime/index.md b/docs/tools/datetime/index.md new file mode 100644 index 000000000..6e792c792 --- /dev/null +++ b/docs/tools/datetime/index.md @@ -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). diff --git a/examples/README.md b/examples/README.md index 3fe1ce37d..596dee6f8 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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 diff --git a/examples/datetime.yaml b/examples/datetime.yaml new file mode 100644 index 000000000..473bfc0f2 --- /dev/null +++ b/examples/datetime.yaml @@ -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 diff --git a/pkg/config/datetime_test.go b/pkg/config/datetime_test.go new file mode 100644 index 000000000..3aa1a478a --- /dev/null +++ b/pkg/config/datetime_test.go @@ -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) + }) + } +} diff --git a/pkg/runtime/datetime_test.go b/pkg/runtime/datetime_test.go new file mode 100644 index 000000000..891c653e2 --- /dev/null +++ b/pkg/runtime/datetime_test.go @@ -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) + } + }) + } + } +} diff --git a/pkg/teamloader/toolsets/catalog.go b/pkg/teamloader/toolsets/catalog.go index b2cb9c597..49ccd8ed0 100644 --- a/pkg/teamloader/toolsets/catalog.go +++ b/pkg/teamloader/toolsets/catalog.go @@ -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"), diff --git a/pkg/teamloader/toolsets/datetime_test.go b/pkg/teamloader/toolsets/datetime_test.go new file mode 100644 index 000000000..0643b3fc5 --- /dev/null +++ b/pkg/teamloader/toolsets/datetime_test.go @@ -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) +} diff --git a/pkg/teamloader/toolsets/toolsets.go b/pkg/teamloader/toolsets/toolsets.go index 05a9aaf3a..c085387ed 100644 --- a/pkg/teamloader/toolsets/toolsets.go +++ b/pkg/teamloader/toolsets/toolsets.go @@ -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, diff --git a/pkg/tools/builtin/datetime/datetime.go b/pkg/tools/builtin/datetime/datetime.go new file mode 100644 index 000000000..7e2e964ee --- /dev/null +++ b/pkg/tools/builtin/datetime/datetime.go @@ -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 +} diff --git a/pkg/tools/builtin/datetime/datetime_test.go b/pkg/tools/builtin/datetime/datetime_test.go new file mode 100644 index 000000000..bdc77faa2 --- /dev/null +++ b/pkg/tools/builtin/datetime/datetime_test.go @@ -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) +} diff --git a/pkg/tools/builtin/datetime/local_js.go b/pkg/tools/builtin/datetime/local_js.go new file mode 100644 index 000000000..ed14f4a87 --- /dev/null +++ b/pkg/tools/builtin/datetime/local_js.go @@ -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 +} diff --git a/pkg/tools/builtin/datetime/local_js_test.go b/pkg/tools/builtin/datetime/local_js_test.go new file mode 100644 index 000000000..cddceb037 --- /dev/null +++ b/pkg/tools/builtin/datetime/local_js_test.go @@ -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}) +} diff --git a/pkg/tools/builtin/datetime/local_native.go b/pkg/tools/builtin/datetime/local_native.go new file mode 100644 index 000000000..ba4910067 --- /dev/null +++ b/pkg/tools/builtin/datetime/local_native.go @@ -0,0 +1,9 @@ +//go:build !js + +package datetime + +import "time" + +func localTime(now time.Time) time.Time { + return now.In(time.Local) +} diff --git a/pkg/tools/builtin/datetime/local_native_test.go b/pkg/tools/builtin/datetime/local_native_test.go new file mode 100644 index 000000000..032126a60 --- /dev/null +++ b/pkg/tools/builtin/datetime/local_native_test.go @@ -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) + } +}