mirror of
https://github.com/docker/docker-agent.git
synced 2026-11-04 10:02:15 +00:00
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:
22 files changed
+692
-11
No files matched your search
+1
-1
@@ -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
@@ -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
@@ -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. |
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user