Repository: griptape-ai/griptape

Issue:
DateTimeTool Relative Dates Don't Work Well
[DateTimeTool.get_relative_datetime](https://github.com/griptape-ai/griptape/blob/ddb40a941d1ba26e878bf8a29686f5ce0d47630d/griptape/tools/date_time/tool.py#L34) is very finicky. [dateparser](https://dateparser.readthedocs.io/en/latest/), the library we use, is not very clear about what wording is supported. It's not uncommon for the LLM to pass a value like `Date in 3 days` which the library fails to parse.

Discussion:

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4fbca657..b74d3782 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,10 +23,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 ## Unreleased
 
 ### Added
+
 - Provider-specific Driver namespaces (e.g., `griptape.drivers.prompt.openai`, `griptape.drivers.embedding.cohere`).
 
-## Added
+### Added
+
 - Tool streaming support to `OllamaPromptDriver`.
+- `DateTimeTool.add_timedelta` and `DateTimeTool.get_datetime_diff` for basic datetime arithmetic.
+
+### Changed
+
+- Added `DateTime.get_relative_datetime` to `DateTimeTool.denylist`. May be removed in a future release.
+
 ### Deprecated
 
 - `griptape.drivers` namespace. Use provider-specific namespaces instead.
diff --git a/docs/griptape-tools/official-tools/src/date_time_tool_1.py b/docs/griptape-tools/official-tools/src/date_time_tool_1.py
index f806e509..5136f174 100644
--- a/docs/griptape-tools/official-tools/src/date_time_tool_1.py
+++ b/docs/griptape-tools/official-tools/src/date_time_tool_1.py
@@ -1,8 +1,6 @@
 from griptape.structures import Agent
 from griptape.tools import DateTimeTool
 
-# Create an agent with the DateTimeTool tool
 agent = Agent(tools=[DateTimeTool()])
 
-# Fetch the current date and time
-agent.run("What is the current date and time?")
+agent.run("What day is 5 days past christmas in 2026?")
diff --git a/griptape/tools/date_time/tool.py b/griptape/tools/date_time/tool.py
index dfdfcc57..4bfb6f9e 100644
--- a/griptape/tools/date_time/tool.py
+++ b/griptape/tools/date_time/tool.py
@@ -1,30 +1,32 @@
-from datetime import datetime
+from __future__ import annotations
 
-from schema import Literal, Schema
+from datetime import datetime, timedelta
+from typing import Optional
+
+import schema
+from attrs import Factory, define, field
 
 from griptape.artifacts import BaseArtifact, ErrorArtifact, TextArtifact
 from griptape.tools import BaseTool
 from griptape.utils.decorators import activity
 
 
+@define
 class DateTimeTool(BaseTool):
+    denylist: list[str] = field(default=Factory(lambda: ["get_relative_datetime"]), kw_only=True)
+
     @activity(config={"description": "Can be used to return current date and time."})
     def get_current_datetime(self) -> BaseArtifact:
-        try:
-            current_datetime = datetime.now()
-
-            return TextArtifact(str(current_datetime))
-        except Exception as e:
-            return ErrorArtifact(f"error getting current datetime: {e}")
+        return TextArtifact(str(datetime.now()))
 
     @activity(
         config={
             "description": "Can be used to return a relative date and time.",
-            "schema": Schema(
+            "schema": schema.Schema(
                 {
-                    Literal(
+                    schema.Literal(
                         "relative_date_string",
-                        description='Relative date in English. For example, "now EST", "20 minutes ago", '
+                        description='Relative date in English compatible with the dateparser library. For example, "now EST", "20 minutes ago", '
                         '"in 2 days", "3 months, 1 week and 1 day ago", or "yesterday at 2pm"',
                     ): str,
                 },
@@ -44,3 +46,47 @@ class DateTimeTool(BaseTool):
                 return ErrorArtifact("invalid date string")
         except Exception as e:
             return ErrorArtifact(f"error getting current datetime: {e}")
+
+    @activity(
+        config={
+            "description": "Can be used to add a timedelta to a datetime.",
+            "schema": schema.Schema(
+                {
+                    schema.Literal(
+                        "timedelta_kwargs",
+                        description='A dictionary of keyword arguments to pass to the timedelta function. For example, {"days": -1, "hours": 2}',
+                    ): dict,
+                    schema.Optional(
+                        schema.Literal(
+                            "iso_datetime",
+                            description='Datetime represented as a string in ISO 8601 format. For example, "2021-01-01T00:00:00". Defaults to the current datetime if not provided.',
+                        )
+                    ): str,
+                },
+            ),
+        },
+    )
+    def add_timedelta(self, timedelta_kwargs: dict, iso_datetime: Optional[str] = None) -> BaseArtifact:
+        if iso_datetime is None:
+            iso_datetime = datetime.now().isoformat()
+        return TextArtifact((datetime.fromisoformat(iso_datetime) + timedelta(**timedelta_kwargs)).isoformat())
+
+    @activity(
+        config={
+            "description": "Can be used to calculate the difference between two datetimes. The difference is calculated as end_datetime - start_datetime.",
+            "schema": schema.Schema(
+                {
+                    schema.Literal(
+                        "start_datetime",
+                        description='Datetime represented as a string in ISO 8601 format. For example, "2021-01-01T00:00:00"',
+                    ): str,
+                    schema.Literal(
+                        "end_datetime",
+                        description='Datetime represented as a string in ISO 8601 format. For example, "2021-01-02T00:00:00"',
+                    ): str,
+                }
+            ),
+        }
+    )
+    def get_datetime_diff(self, start_datetime: str, end_datetime: str) -> BaseArtifact:
+        return TextArtifact(str(datetime.fromisoformat(end_datetime) - datetime.fromisoformat(start_datetime)))


Test patch:
diff --git a/tests/unit/tools/test_date_time.py b/tests/unit/tools/test_date_time.py
index 9fa2ce4b..bd21ad4f 100644
--- a/tests/unit/tools/test_date_time.py
+++ b/tests/unit/tools/test_date_time.py
@@ -1,9 +1,11 @@
-from datetime import datetime
+from datetime import datetime, timedelta
+
+import pytest
 
 from griptape.tools import DateTimeTool
 
 
-class TestDateTime:
+class TestDateTimeTool:
     def test_get_current_datetime(self):
         result = DateTimeTool().get_current_datetime({})
         time_delta = datetime.strptime(result.value, "%Y-%m-%d %H:%M:%S.%f") - datetime.now()
@@ -26,3 +28,64 @@ class TestDateTime:
     def test_get_invalid_relative_datetime(self):
         result = DateTimeTool().get_relative_datetime({"values": {"relative_date_string": "3 days from now"}})
         assert result.type == "ErrorArtifact"
+
+    @pytest.mark.parametrize(
+        ("iso_datetime", "timedelta_kwargs", "expected"),
+        [
+            (
+                "2025-01-23T15:09:11.111421",
+                {"days": 1, "hours": 2},
+                datetime.fromisoformat("2025-01-23T15:09:11.111421") + timedelta(days=1, hours=2),
+            ),
+            (
+                "2025-01-23T15:09:11.111421",
+                {"days": -1, "hours": 2},
+                datetime.fromisoformat("2025-01-23T15:09:11.111421") + timedelta(days=-1, hours=2),
+            ),
+        ],
+    )
+    def test_add_timedelta(self, iso_datetime, timedelta_kwargs, expected):
+        result = DateTimeTool().add_timedelta(
+            {
+                "values": {
+                    **({"iso_datetime": iso_datetime} if iso_datetime else {}),
+                    **({"timedelta_kwargs": timedelta_kwargs} if timedelta_kwargs else {}),
+                }
+            }
+        )
+
+        assert datetime.fromisoformat(result.value) == expected
+
+    def test_add_timedelta_no_iso_datetime(self):
+        result = DateTimeTool().add_timedelta({"values": {"timedelta_kwargs": {"days": 1, "hours": 2}}})
+
+        assert (
+            datetime.fromisoformat(result.value) - (datetime.now() + timedelta(days=1, hours=2))
+        ).total_seconds() <= 100
+
+    @pytest.mark.parametrize(
+        ("start_datetime", "end_datetime", "expected"),
+        [
+            (
+                "2025-01-23T15:09:11.111421",
+                "2025-01-24T17:09:11.111421",
+                "1 day, 2:00:00",
+            ),
+            (
+                "2025-01-23T15:09:11.111421",
+                "2025-01-23T15:09:11.111421",
+                "0:00:00",
+            ),
+            (
+                "2025-01-23T15:09:11.111421",
+                "2025-01-22T17:09:11.111421",
+                "-1 day, 2:00:00",
+            ),
+        ],
+    )
+    def test_get_datetime_diff(self, start_datetime, end_datetime, expected):
+        result = DateTimeTool().get_datetime_diff(
+            {"values": {"start_datetime": start_datetime, "end_datetime": end_datetime}}
+        )
+
+        assert result.value == expected