diff --git a/.env.template b/.env.template
index 3e80cf02..72c0fd53 100644
--- a/.env.template
+++ b/.env.template
@@ -15,3 +15,6 @@ DIAL_API_KEY=dial_api_key
# App settings
LOG_FORMAT=[%(asctime)s] - %(levelname)s - %(message)s
LOG_MULTILINE_LOG_ENABLED=true
+
+# Enable preview features for local development (production default: false)
+ENABLE_PREVIEW_FEATURES=true
diff --git a/docs/README.md b/docs/README.md
index 6d0a7be3..bdcd9bd6 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -17,7 +17,8 @@ See [Feature Lifecycle](../README.md#feature-lifecycle) for details.
| Document | Description |
|---------------------------------------|------------------------------------------------------------------------------|
-| [Agent Skills](skills.md) `[Preview]` | How to create and manage reusable agent skills (directory layout, metadata). |
+| [Agent Skills](skills.md) `[Preview]` | How to create and manage reusable agent skills (directory layout, metadata). |
+| [Time Awareness](time_awareness.md) `[Preview]` | How the agent knows the current time and reasons about data freshness. |
## Diagrams
diff --git a/docs/agent.md b/docs/agent.md
index 1088adce..5aebeadd 100644
--- a/docs/agent.md
+++ b/docs/agent.md
@@ -164,7 +164,9 @@ When the LLM requests multiple tools, the Tool Executor runs them concurrently u
2. Invoked with parsed arguments
3. Timed for performance tracking
-Results are collected and returned in order matching the original tool calls.
+Results are collected and returned in order matching the original tool calls. After execution,
+`CompletionResultEnricher` instances are applied to each result (e.g. the timestamp metadata enricher stamps every
+result with its production time).
### Stage Wrapper Pattern
@@ -208,12 +210,18 @@ Messages undergo processing both before being sent to the LLM and when receiving
### Pre-Transformer Pipeline
-All message transformers extend the typed `MessagesTransformer` base class and are registered via the `AgentModule`
-and `AttachmentProcessingModule` DI providers. They run once at request setup via `_MessagesSetup`, called from
-`_RequestContextSetup.setup()`. `_MessagesSetup` returns a new transformed list of messages. The `AssistantInvoker`
-then uses the transformed messages directly without any copying or additional preprocessing.
+Message transformers are organized into two tiers:
-The pipeline runs the following steps in order:
+| Tier | When it runs | Mutation safety |
+|----------------------------|----------------------------------------|-----------------------------------------------------|
+| `MessagesTransformer` | Once, in `_MessagesSetup.setup()` | Mutates the canonical message list |
+| `PreInvocationTransformer` | Every iteration, in `AssistantInvoker` | Each transformer selectively copies what it mutates |
+
+`MessagesTransformer` implementations run once at request setup via `_MessagesSetup`, called from
+`_RequestContextSetup.setup()`. `PreInvocationTransformer` implementations run before every LLM call in
+`AssistantInvoker.__prepare_messages()` — their changes are transient and never persisted to history.
+
+The setup pipeline runs the following steps in order:
1. **Tool Call Extraction**: Not a transformer — runs first in `_MessagesSetup.setup()`. Expands prior-turn tool calls
packed in `custom_content.state[TOOL_EXECUTION_HISTORY]` into proper ASSISTANT + TOOL message pairs. This must run
@@ -228,6 +236,18 @@ The pipeline runs the following steps in order:
notification. If changes are detected, inserts synthetic tool call and tool result message pairs into the history
using the `available_context` tool. Returns messages unchanged when inactive.
+4. **Timestamp Injection Transformer** (`_TimestampInjectionTransformer`, preview): Appends a synthetic
+ `current_timestamp` tool-call + result pair at the end of the message list so the agent knows "when" the
+ interaction is happening. Historical timestamps are restored from state with their original times.
+
+### Pre-Invocation Transformers
+
+Before each LLM call, `AssistantInvoker` runs all `PreInvocationTransformer` instances. Current implementations:
+
+1. **Attachment Filter** (`_AttachmentFilter`): Filters unsupported attachment types and injects attachment XML metadata.
+2. **Timestamp Annotation Transformer** (`_TimestampAnnotationTransformer`, preview): Appends human-readable
+ `[Timestamp: ...]` annotations to tool messages that carry timestamp metadata.
+
### Streaming Response Processing
LLM responses are streamed and processed incrementally by the Chunk Processor:
@@ -321,7 +341,7 @@ Quick Apps uses dependency injection extensively to manage component lifecycle a
### Module Architecture
-The application is composed of 12 specialized DI modules:
+The application is composed of 13 specialized DI modules:
1. **App Module**: Core application, request context, FastAPI setup
2. **Agent Module**: Orchestrator, assistant invoker, message transformers
@@ -334,7 +354,8 @@ The application is composed of 12 specialized DI modules:
9. **DIAL Core Services Module**: DIAL Core integration (`InteractiveLoginService`, `InteractiveLoginSettings`)
10. **File Transfer Module**: `ToolArgumentTransformer` for `file:` prefix resolution, file transfer instruction injection
11. **Attachment Processing Module**: Context notification tool, attachment change detection injector
-12. **Skills Module**: Skill reader tool, agent skills provider
+12. **Timestamp Module** (preview): Timestamp tool, injection/annotation transformers, metadata enricher
+13. **Skills Module**: Skill reader tool, agent skills provider
### Scoping
diff --git a/docs/designs/preview_feature_gating.md b/docs/designs/preview_feature_gating.md
index 8226866a..a5ff662f 100644
--- a/docs/designs/preview_feature_gating.md
+++ b/docs/designs/preview_feature_gating.md
@@ -6,7 +6,7 @@
## Problem Statement
-As new features are added to QuickApps (e.g. timestamp awareness), some need to be shipped in
+As new features are added to QuickApps (e.g. time awareness), some need to be shipped in
a "preview" state — available for early testing but not yet considered stable. Today there is no
mechanism to:
@@ -282,7 +282,7 @@ documentation suffices.
```python
class Features(BaseModel):
timestamp: TimestampConfig | None = PreviewField(
- default=None, description="Timestamp awareness configuration."
+ default=None, description="Time awareness configuration."
)
```
@@ -291,7 +291,7 @@ class Features(BaseModel):
```python
class Features(BaseModel):
timestamp: TimestampConfig | None = Field(
- default=None, description="Timestamp awareness configuration."
+ default=None, description="Time awareness configuration."
)
```
diff --git a/docs/designs/template.md b/docs/designs/template.md
index 0c22ffc0..50b96b16 100644
--- a/docs/designs/template.md
+++ b/docs/designs/template.md
@@ -1,6 +1,8 @@
# Design: [Title]
-**Status:** Draft | Approved | Implemented | Superseded
+- **Status:** Draft | Approved | Implemented | Superseded
+- **Dependencies:**
+ - None | [Link to dependent design doc(s)]
## Problem Statement
diff --git a/docs/designs/time_awareness.md b/docs/designs/time_awareness.md
new file mode 100644
index 00000000..ede0831c
--- /dev/null
+++ b/docs/designs/time_awareness.md
@@ -0,0 +1,535 @@
+# Design: Time Awareness
+
+- **Status:** Implemented
+- **Dependencies:**
+ - [Preview Feature Gating](preview_feature_gating.md)
+
+## Problem Statement
+
+LLMs have no inherent sense of time. When a user asks "what happened today?" or "schedule this for
+tomorrow", the agent cannot reason about temporal context because it has no access to the current
+date or time.
+
+Today the only workaround is to have a general-purpose tool (e.g. the Python code interpreter)
+that can return `datetime.now()`. This has two problems:
+
+1. **Latency and waste** — the agent must decide to call a heavy tool just to learn the time,
+ adding a full tool-call round-trip to every time-sensitive interaction.
+2. **No temporal context for tool results** — when the agent receives data from tools (API
+ responses, search results, fetched content), it has no way to know *when* that data was
+ produced. It cannot reason about freshness or staleness.
+
+## Design Goals
+
+- The agent can determine the current date and time via a lightweight, dedicated tool.
+- The current timestamp is automatically injected into the conversation at every user turn, so the
+ agent always knows "when" the interaction is happening without an explicit tool call.
+- Every tool response is annotated with its production timestamp, so the agent can reason about
+ data freshness.
+- The design is timezone-aware from the start (defaulting to UTC), with a clear extension point for
+ request-level timezone configuration in the future.
+- The timestamp tool lives in its own module (`timestamp_tooling`), independent of the existing
+ internal tooling.
+
+---
+
+## Use Cases
+
+### UC-1: User asks a time-sensitive question
+
+- **Trigger:** User sends "What day is it today?" to an app with the timestamp tool enabled.
+- **Behavior:** The agent sees the auto-injected timestamp in the conversation and answers directly.
+- **Outcome:** The agent responds with the correct current date without making any tool calls.
+
+### UC-2: Agent has access to data freshness information
+
+- **Trigger:** The agent calls a REST API tool that returns market data during a multi-iteration loop.
+ Several iterations later, the agent considers whether to re-fetch.
+- **Behavior:** Each tool response carries a human-readable timestamp annotation. The agent can
+ compare the annotation on the earlier result with the current time (from the auto-injected
+ timestamp).
+- **Outcome:** The agent has access to freshness information and can factor it into its decisions.
+ Note: whether the LLM reliably acts on this depends on the model's temporal reasoning ability.
+
+### UC-3: Agent plans a future action
+
+- **Trigger:** User says "Remind me about this in 2 hours" or "What's the deadline if it's 3 days
+ from now?"
+- **Behavior:** The agent reads the auto-injected current timestamp and performs arithmetic on it.
+- **Outcome:** The agent produces correct absolute dates/times relative to "now."
+
+### UC-4: User asks for time in a specific timezone
+
+- **Trigger:** User asks "What time is it in Tokyo right now?"
+- **Behavior:** The agent calls `current_timestamp` with `{"timezone": "Asia/Tokyo"}`. The tool
+ returns the current time converted to the requested timezone via `ZoneInfo`.
+- **Outcome:** The agent responds with an authoritative, server-computed time in the requested
+ timezone — no LLM arithmetic required, avoiding errors with DST or unusual offset rules.
+
+### UC-5: Multi-turn conversation spanning time
+
+- **Trigger:** A conversation spans several minutes. The user sends a new message after a pause.
+- **Behavior:** Each user turn gets a fresh auto-injected timestamp. The agent can see the
+ progression of time across turns.
+- **Outcome:** The agent can reference "your previous message was 5 minutes ago" or "since your
+ last message, the data may have changed."
+
+---
+
+## Proposed Design
+
+The feature has five cooperating concerns:
+
+```mermaid
+flowchart TD
+ subgraph "Request Setup (once)"
+ A["Timestamp Injection Transformer
(MessagesTransformer)"]
+ end
+ subgraph "Per-Iteration (each LLM call)"
+ B["Timestamp Annotation Transformer
(PreInvocationTransformer)"]
+ C["Timestamp Metadata Enricher
(CompletionResultEnricher)"]
+ end
+ D["CurrentTimestampTool"]
+ E["TimeProvider"]
+ E --> A
+ E --> C
+ E --> D
+ A -- " single synthetic tool call + result
appended at end of message list " --> B
+ C -- " metadata on every
tool response " --> B
+ B -- " annotated messages
(selective deep copies) " --> LLM["LLM"]
+```
+
+### 1. Timestamp Tool
+
+- **What:** A new `_CurrentTimestampTool` extending `StagedBaseTool`, registered within the
+ `timestamp_tooling` module.
+- **Owner:** `timestamp_tooling` module.
+- **Semantics:** Accepts an optional `timezone` parameter (IANA name, e.g. `"Asia/Tokyo"`).
+ Defaults to UTC. Returns the current date and time as ISO 8601 with timezone name and source
+ (`default` or `request`).
+- **Change:** New tool class, new DI module.
+
+The tool is not part of any user-configured toolset. `TimestampModule` registers it
+conditionally (when time awareness is enabled) via `@multiprovider`, following the
+`AttachmentProcessingModule` / `_AvailableContextTool` pattern.
+
+The auto-injected timestamp already gives the agent "now" in UTC. The tool's primary purpose is
+explicit timezone conversion (UC-4) — the agent calls it with a `timezone` parameter when the
+user asks about a specific timezone.
+
+### 2. Auto-Injection via MessagesTransformer
+
+- **What:** `_TimestampInjectionTransformer`, a `MessagesTransformer` that appends a single
+ synthetic tool-call + tool-result pair at the end of the message list.
+- **Owner:** `timestamp_tooling` module.
+- **Semantics:** Runs once at request setup (in `_MessagesSetup`). Appends a synthetic
+ assistant message with a tool call to `current_timestamp` and a corresponding tool result
+ containing the current UTC time at the end of the message list. Uses a deterministic
+ synthetic ID with a known prefix (e.g. `call_synthetic_timestamp_`). Other transformers
+ (e.g. `_AttachmentNotificationInjector`) may also append messages — ordering between them
+ does not matter. From the agent's perspective: "I saw the user's message, I checked the
+ time, now I respond."
+- **Change:** New transformer registered via `@multiprovider` as `list[MessagesTransformer]`.
+
+**History persistence:** The synthetic timestamp messages are appended at the end of the
+message list, placing them inside the window captured by `_build_tool_execution_history()`. On
+the next request, `extract_tool_calls()` restores them with the **original** timestamp. The
+transformer then appends a new timestamp at the end of the new message list. Each turn's
+timestamp is preserved with its correct historical time (UC-5).
+
+**Token cost:** Only 2 extra messages per turn (one assistant tool call + one tool result).
+Historical timestamps are restored from state, not re-injected.
+
+### 3. Tool Response Metadata Enrichment
+
+- **What:** `CompletionResultEnricher` ABC in `common/abstract/` and
+ `_TimestampMetadataEnricher` implementation in `timestamp_tooling`.
+- **Owner:** ABC in `common/abstract/`, implementation in `timestamp_tooling`.
+- **Semantics:** After each tool completes in `ToolExecutor`, the enricher stamps the result's
+ state with timestamp metadata (production time, timezone, source). Uses "fill if absent"
+ semantics — if a tool already set metadata, the enricher preserves it.
+- **Change:** `ToolExecutor` receives `list[CompletionResultEnricher]` via DI and runs them
+ on each `CompletionResult` after tool execution. The ABC is needed to keep `agent/` decoupled
+ from `timestamp_tooling/` — `ToolExecutor` depends on the abstraction, not the concrete
+ implementation.
+
+The metadata is stored in `CompletionResult.state` under a `_message_metadata` key, using a
+`MessageMetadata` Pydantic model that nests `TimestampMetadata`.
+
+### 4. Per-Invocation Annotation Transformer
+
+- **What:** A new transformer tier (`PreInvocationTransformer`) that runs before every LLM call,
+ and a `_TimestampAnnotationTransformer` that appends human-readable timestamp strings to tool
+ messages.
+- **Owner:** `PreInvocationTransformer` ABC in `common/abstract/base_transformer.py`;
+ annotation transformer in `timestamp_tooling`.
+- **Semantics:**
+ - `AssistantInvoker.__prepare_messages()` runs all `PreInvocationTransformer` instances
+ before each LLM call. This ensures annotations never leak into the persisted message
+ history.
+ - Each transformer is responsible for its own deep-copy strategy — it copies only the
+ messages it mutates, leaving the rest as references (same approach as `_AttachmentFilter`
+ today).
+ - `_TimestampAnnotationTransformer` iterates tool messages, reads `_message_metadata` from
+ state, and appends an annotation like `\n[Timestamp: 2026-01-15 12:30:00 UTC]`.
+ - The transformer skips messages whose `tool_call_id` starts with the synthetic timestamp
+ prefix (`call_synthetic_timestamp_`) to avoid double-annotating timestamp tool results.
+ This is simpler than looking up the tool name from the preceding assistant message.
+ - Annotations are appended to `msg.content` which the LLM always reads as text, regardless
+ of the tool's logical content type. Downstream components are unaffected because
+ annotations only exist in the per-invocation copies, never in the persisted history.
+- **Change:**
+ - New `PreInvocationTransformer` ABC alongside existing `MessagesTransformer`.
+ - `AssistantInvoker` receives `list[PreInvocationTransformer]` via DI and applies them.
+ - `_AttachmentFilter` is refactored into a `PreInvocationTransformer` (it already operates
+ on selective deep copies per-invocation — this formalizes the pattern).
+
+### 5. TimeProvider
+
+- **What:** A request-scoped provider that returns the current time in a configured timezone.
+- **Owner:** `timestamp_tooling` module.
+- **Semantics:** Constructed at request scope with a `ZoneInfo` timezone and a
+ `TimestampSource` enum (`DEFAULT` or `REQUEST`). Defaults to UTC / `DEFAULT`. Calls
+ `datetime.now(tz)` on each invocation — it is a provider, not a snapshot. Each tool result
+ gets stamped with its actual production time, even if the orchestrator loop spans multiple
+ iterations. When the request carries a timezone (future extension point), the provider is
+ constructed with that timezone and `TimestampSource.REQUEST`. This is the natural DI seam
+ for request-level timezone — when it arrives, only the provider construction changes.
+- **Change:** New class, bound in `TimestampModule` at request scope.
+
+### 6. Transformer Hierarchy
+
+The current codebase has a single `MessagesTransformer` ABC and a separate `_AttachmentFilter`
+that is not a transformer. This design adds a second tier — `PreInvocationTransformer` — without
+renaming the existing `MessagesTransformer`:
+
+```mermaid
+classDiagram
+ class MessagesTransformer {
+ <>
+ +transform(messages) list~Message~
+ }
+ class PreInvocationTransformer {
+ <>
+ +transform(messages) list~Message~
+ }
+
+ MessagesTransformer <|-- _AddSystemPromptTransformer
+ MessagesTransformer <|-- _AttachmentNotificationInjector
+ MessagesTransformer <|-- _InjectFileTransferInstructionTransformer
+ MessagesTransformer <|-- _TimestampInjectionTransformer
+ PreInvocationTransformer <|-- _AttachmentFilter
+ PreInvocationTransformer <|-- _TimestampAnnotationTransformer
+```
+
+| Tier | When it runs | Mutation safety |
+|----------------------------|----------------------------------------|-----------------------------------------------------|
+| `MessagesTransformer` | Once, in `_MessagesSetup.setup()` | Mutates the canonical message list |
+| `PreInvocationTransformer` | Every iteration, in `AssistantInvoker` | Each transformer selectively copies what it mutates |
+
+### 7. Configurability
+
+Time awareness is a feature-level concern that impacts the whole app (tool registration,
+message transformation, tool result enrichment), not just the orchestrator. It is configured
+under a new top-level `features` section in `ApplicationConfig`.
+
+#### Features model
+
+A new `Features` model groups optional, independently toggleable capabilities. Each capability
+is an optional config object — presence enables the feature. `Features` lives at the
+`ApplicationConfig` level and defaults to an empty instance (all features `None`) to avoid
+double null checks in code:
+
+```python
+class Features(BaseModel):
+ timestamp: TimestampConfig | None = PreviewField(
+ default_factory=ToolCallTimestampConfig,
+ description="Time awareness configuration.",
+ )
+
+class ApplicationConfig(BaseApplicationTypeConfig):
+ # ... existing fields ...
+ features: Features = Field(
+ default_factory=Features,
+ description="Optional feature flags.",
+ )
+```
+
+`Features.timestamp` uses the `TimestampConfig` alias from the start. A discriminated union
+with one variant is functionally identical to the concrete type, so there's no cost today.
+When a second strategy is added, only the union definition changes — `Features` stays
+untouched.
+
+The `features` field has `propertyKind: "server"` (backend concern, not client-visible).
+`propertyOrder` is auto-assigned by position (last among top-level fields).
+
+An app opts in with:
+
+```json
+{
+ "orchestrator": {
+ "deployment": {
+ "name": "gpt-4o"
+ }
+ },
+ "features": {
+ "timestamp": {}
+ }
+}
+```
+
+When disabled (no `features` section, no `timestamp` key, or `"timestamp": null`):
+
+- `TimestampModule` does not register the tool, injection transformer, annotation transformer,
+ or metadata enricher.
+- No synthetic messages are injected, no tool responses are annotated.
+- Zero overhead for apps that do not need time awareness.
+
+Code access is always `config.features.timestamp` — no outer null check needed.
+
+#### TimestampConfig and injection strategy extensibility
+
+`TimestampConfig` is a discriminated union keyed on `injection_strategy`. Each strategy has
+its own config model with strategy-specific properties. Initially only `ToolCallTimestampConfig`
+exists:
+
+```python
+class ToolCallTimestampConfig(BaseModel):
+ injection_strategy: Literal["tool_call"] = "tool_call"
+
+
+# Type alias — currently a single variant. When a second strategy is added,
+# change this to a discriminated union:
+# TimestampConfig = Annotated[
+# ToolCallTimestampConfig | SystemPromptTimestampConfig,
+# Discriminator("injection_strategy"),
+# ]
+TimestampConfig = ToolCallTimestampConfig
+```
+
+`TimestampModule` matches on the config type and registers the appropriate components. When
+a new strategy is needed (e.g. system prompt injection), a new config model is added to the
+union and the module registers a `PromptPartProvider` instead of
+`_TimestampInjectionTransformer`. Adding a variant to the union is a non-breaking change
+(existing configs with `"injection_strategy": "tool_call"` or no `injection_strategy` key
+continue to parse as `ToolCallTimestampConfig`). Each strategy's config model carries only
+the properties relevant to that strategy — no shared fields accumulate unused options.
+
+#### Preview feature gating
+
+For this release cycle, `TimestampModule` is a preview module and the `timestamp` field uses
+`PreviewField`.
+
+---
+
+## Secondary Fixes
+
+### AttachmentFilter formalization
+
+`_AttachmentFilter` currently lives outside the transformer hierarchy — it's called directly by
+`AssistantInvoker` and performs its own deep copies. With the `PreInvocationTransformer`
+abstraction, it becomes a proper transformer registered via DI. `AssistantInvoker` no longer
+needs to know about `_AttachmentFilter` specifically; it just runs all pre-invocation
+transformers.
+
+### MessageMetadata model
+
+A shared `MessageMetadata` model (in `common/`) provides typed access to tool message state.
+This replaces ad-hoc `custom_content.state` dict access with a structured model:
+
+```python
+class TimestampSource(StrEnum):
+ DEFAULT = "default" # timezone not provided, defaulted to UTC
+ REQUEST = "request" # timezone provided in the request
+
+
+class TimestampMetadata(BaseModel):
+ response_timestamp: datetime | None = None
+ timestamp_source: TimestampSource | None = None
+ timezone_name: str | None = None
+
+
+class MessageMetadata(BaseModel):
+ timestamp: TimestampMetadata | None = None
+```
+
+---
+
+## Out of Scope
+
+### User timezone from request headers
+
+The design prepares for this (via `TimeProvider` with configurable timezone and
+`TimestampSource.REQUEST` provenance), but actual header extraction and request-level
+timezone override are deferred. **Why:** DIAL Core does not currently pass timezone in request
+headers. When it does, the only change needed is populating `TimeProvider` from the header in
+request setup.
+
+### System prompt injection strategy
+
+An alternative injection strategy that injects the current timestamp directly into the system
+prompt via a `PromptPartProvider` instead of using synthetic tool-call messages. When needed,
+a `SystemPromptTimestampConfig` model is added to the `TimestampConfig` discriminated union
+and handled by `TimestampModule` — the DI seam already supports this (see §7). **Why deferred:**
+The tool-call strategy covers all current use cases and preserves per-turn history naturally.
+The system prompt strategy may be preferable for models that handle system prompts better than
+synthetic tool calls, but this needs validation.
+
+### Agent-learned timezone from conversation
+
+The agent could learn the user's timezone from conversation context (e.g. "I'm in Warsaw") and
+apply it to subsequent timestamps. Deferred because it adds complexity (timezone persistence
+across turns, state management) and the auto-injection with UTC covers the primary use cases.
+
+---
+
+## Configuration / Usage Examples
+
+### Tool config (inline in code)
+
+The tool config is defined inline in a `_tool_configs.py` module (same pattern as
+`_AvailableContextTool`), not in a predefined JSON file. This keeps the tool self-contained
+within the `timestamp_tooling` module since it is not user-customizable.
+
+```python
+CURRENT_TIMESTAMP_TOOL_CONFIG = InternalTool(
+ open_ai_tool=OpenAiToolConfig(
+ function=OpenAiToolFunction(
+ name="current_timestamp",
+ description="Returns the current date and time. Optionally converts to a specific timezone.",
+ parameters=OpenAiToolFunctionParameters(
+ type=JsonTypeEnum.object,
+ properties={
+ "timezone": ConfigurableSchemaSimpleType(
+ type=JsonTypeEnum.string,
+ description="IANA timezone name (e.g. 'Asia/Tokyo'). Defaults to UTC.",
+ )
+ },
+ ),
+ )
+ ),
+)
+```
+
+### Module registration
+
+`TimestampModule` registers the tool and all transformers directly via `@multiprovider`,
+following the same pattern as `AttachmentProcessingModule` with `_AvailableContextTool`. The
+registration is conditional on the presence of a `timestamp` section in
+`ApplicationConfig.features`.
+
+### What the LLM sees (auto-injected)
+
+First turn — synthetic timestamp appended at the end of the message list. Other transformers
+(e.g. `_AttachmentNotificationInjector`) may also append messages; the timestamp appears after
+them:
+
+```
+[system] You are a helpful assistant...
+
+[user] What day is it?
+
+[assistant] (tool_call: current_timestamp → {})
+[tool] 2026-03-24T14:30:00+00:00 (UTC, source=default)
+
+[assistant] Today is Monday, March 24, 2026.
+```
+
+When the app has contexts configured, context notifications appear between the user message
+and the timestamp:
+
+```
+[user] What day is it?
+
+[assistant] (tool_call: available_context → {})
+[tool] {"entries": [...]}
+
+[assistant] (tool_call: current_timestamp → {})
+[tool] 2026-03-24T14:30:00+00:00 (UTC, source=default)
+```
+
+Second turn — previous timestamp restored from history, new one appended at the end:
+
+```
+[system] You are a helpful assistant...
+
+[user] What day is it?
+
+[assistant] (tool_call: current_timestamp → {})
+[tool] 2026-03-24T14:30:00+00:00 (UTC, source=default)
+
+[assistant] Today is Monday, March 24, 2026.
+
+[user] And what time is it now?
+
+[assistant] (tool_call: current_timestamp → {})
+[tool] 2026-03-24T14:35:12+00:00 (UTC, source=default)
+```
+
+### Tool response annotation example
+
+After the metadata enricher runs, a REST API tool response that originally contained:
+
+```
+{"temperature": 22, "unit": "celsius"}
+```
+
+Is seen by the LLM (after the annotation transformer) as:
+
+```
+{"temperature": 22, "unit": "celsius"}
+[Timestamp: 2026-03-24 14:30:00 UTC]
+```
+
+---
+
+## Migration
+
+### Breaking changes
+
+None.
+
+### Non-breaking changes
+
+- New `features` field on `ApplicationConfig` — defaults to empty `Features()`.
+- New `CompletionResultEnricher` pipeline in `ToolExecutor` — empty list means no change.
+- New `PreInvocationTransformer` pipeline in `AssistantInvoker` — empty list means no change.
+- `_AttachmentFilter` becoming a `PreInvocationTransformer` is an internal refactor with no
+ config or behavioral change.
+
+## Summary of Changes
+
+### `common/abstract/`
+
+- **Add** `CompletionResultEnricher` ABC (`completion_result_enricher.py`)
+- **Add** `PreInvocationTransformer` ABC (in `base_transformer.py`)
+
+### `common/`
+
+- **Add** `MessageMetadata`, `TimestampMetadata`, `TimestampSource` (`message_metadata.py`)
+- **Add** `TimeProvider` (`time_provider.py`)
+
+### `timestamp_tooling/` (new module)
+
+- **Add** `_tool_configs.py` — inline tool config (same pattern as `_AvailableContextTool`)
+- **Add** `_CurrentTimestampTool` — the tool implementation
+- **Add** `_TimestampInjectionTransformer` — auto-injects timestamp at end of message list
+- **Add** `_TimestampAnnotationTransformer` — annotates tool messages per-invocation
+- **Add** `_TimestampMetadataEnricher` — stamps every tool result with production time
+- **Add** `TimestampModule` — DI wiring
+
+### `config/`
+
+- **Add** `ToolCallTimestampConfig` model, `TimestampConfig` discriminated union type alias
+- **Add** `Features` model with `PreviewField`-annotated fields
+- **Modify** `ApplicationConfig` — add `features: Features` field (defaults to empty `Features()`)
+
+### `agent/`
+
+- **Modify** `ToolExecutor.execute()` — run `CompletionResultEnricher` chain after tool execution
+- **Modify** `AssistantInvoker.__prepare_messages()` — run `PreInvocationTransformer` chain
+- **Refactor** `_AttachmentFilter` → `PreInvocationTransformer` subclass
+
+### `app_factory.py`
+
+- **Add** `TimestampModule` to the module list
diff --git a/docs/generated-app-schema.json b/docs/generated-app-schema.json
index 503913a6..a01e87f0 100644
--- a/docs/generated-app-schema.json
+++ b/docs/generated-app-schema.json
@@ -1073,6 +1073,24 @@
"title": "DialSystemPromptConfig",
"type": "object"
},
+ "Features": {
+ "properties": {
+ "timestamp": {
+ "anyOf": [
+ {
+ "$ref": "#/$defs/ToolCallTimestampConfig"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Time awareness configuration.",
+ "x-preview": true
+ }
+ },
+ "title": "Features",
+ "type": "object"
+ },
"FileContextConfig": {
"properties": {
"type": {
@@ -2517,6 +2535,18 @@
"title": "StopStrategyModel",
"type": "object"
},
+ "ToolCallTimestampConfig": {
+ "properties": {
+ "injection_strategy": {
+ "const": "tool_call",
+ "default": "tool_call",
+ "title": "Injection Strategy",
+ "type": "string"
+ }
+ },
+ "title": "ToolCallTimestampConfig",
+ "type": "object"
+ },
"ToolDisplayConfig": {
"properties": {
"stage": {
@@ -2843,6 +2873,21 @@
"dial:propertyKind": "server",
"dial:propertyOrder": 5
}
+ },
+ "features": {
+ "anyOf": [
+ {
+ "$ref": "#/$defs/Features"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "QuickApps Agent features configuration.",
+ "dial:meta": {
+ "dial:propertyKind": "server",
+ "dial:propertyOrder": 6
+ }
}
},
"required": [
diff --git a/docs/time_awareness.md b/docs/time_awareness.md
new file mode 100644
index 00000000..97c03859
--- /dev/null
+++ b/docs/time_awareness.md
@@ -0,0 +1,78 @@
+# Time Awareness `[Preview]`
+
+> [!IMPORTANT]
+> Time Awareness is a **preview** feature. Its API and behavior may change in breaking ways without a major
+> version bump. See [Feature Lifecycle](../README.md#feature-lifecycle) for details.
+
+## Why
+
+LLMs have no inherent sense of time. Without external help, the agent cannot answer "what day is it?", reason
+about whether fetched data is stale, or compute deadlines relative to "now."
+
+Time awareness solves this by giving the agent access to the current time and annotating tool results with
+their production timestamps.
+
+## What it does
+
+When enabled, the feature provides three capabilities:
+
+1. **The agent always knows the current time.** At every user turn, the current UTC timestamp is automatically
+ injected into the conversation. The agent can answer time-sensitive questions immediately, without making any
+ tool calls.
+
+2. **The agent can convert timezones.** If a user asks "what time is it in Tokyo?", the agent calls the
+ `current_timestamp` tool with `timezone: "Asia/Tokyo"`. The conversion is done server-side, avoiding LLM
+ errors with DST or unusual offset rules.
+
+3. **Tool results carry freshness information.** Every tool response is automatically annotated with the time
+ it was produced. When the agent executes multiple tools across several iterations, it can compare each
+ result's production time against the current time and assess staleness. For example, if a weather API was
+ called 10 minutes ago and the user asks a follow-up question, the agent can see the age of the data and
+ decide whether to re-fetch or reuse the earlier result.
+
+## How to enable
+
+Add a `features` section to the application config:
+
+```json
+{
+ "orchestrator": { "deployment": { "name": "gpt-4o" } },
+ "features": {
+ "timestamp": {}
+ }
+}
+```
+
+The feature is enabled by default when preview features are active. To explicitly disable it for a specific app,
+set `"timestamp": null`.
+
+## What the agent sees
+
+On the first turn, the agent receives the current time as if it had called a tool:
+
+```
+[user] What day is it?
+[assistant] (tool_call: current_timestamp -> {})
+[tool] 2026-03-24T14:30:00+00:00 (UTC, source=default)
+[assistant] Today is Monday, March 24, 2026.
+```
+
+On subsequent turns, previous timestamps are preserved with their original times, and a fresh timestamp is
+appended — so the agent can see the progression of time across the conversation.
+
+### Tool result annotations
+
+Every tool response (REST APIs, deployments, MCP tools, etc.) is annotated with its production time before
+being sent to the LLM:
+
+```
+{"temperature": 22, "unit": "celsius"}
+[Timestamp: 2026-03-24 14:30:00 UTC]
+```
+
+The agent can compare this against the current time to reason about freshness. For instance, if the agent
+fetched stock prices at 14:30 and the user asks again at 14:45, the agent sees a 15-minute gap and can decide
+to call the API again rather than reusing stale data.
+
+These annotations are transient — they are visible only to the LLM during processing and do not appear in the
+persisted conversation history or in the response shown to the user.
diff --git a/src/quickapp/agent/_attachment_filter.py b/src/quickapp/agent/_attachment_filter.py
index 697166a1..68ade386 100644
--- a/src/quickapp/agent/_attachment_filter.py
+++ b/src/quickapp/agent/_attachment_filter.py
@@ -4,19 +4,20 @@
from aidial_sdk.chat_completion import Attachment, Message, Role
+from quickapp.common.abstract.base_transformer import PreInvocationTransformer
from quickapp.common.utils import matches_type
logger = logging.getLogger(__name__)
-class _AttachmentFilter:
+class _AttachmentFilter(PreInvocationTransformer):
SUPPORTED_ATTACHMENTS = ["image/*"]
@staticmethod
def _has_attachments(message: Message) -> bool:
return message.custom_content is not None and bool(message.custom_content.attachments)
- def filter_attachments(self, messages: list[Message]) -> list[Message]:
+ def transform(self, messages: list[Message]) -> list[Message]:
for item in messages:
if not isinstance(item, Message):
raise TypeError("All items must be Message instances")
diff --git a/src/quickapp/agent/agent_module.py b/src/quickapp/agent/agent_module.py
index e9f567ea..85ab7aaa 100644
--- a/src/quickapp/agent/agent_module.py
+++ b/src/quickapp/agent/agent_module.py
@@ -14,7 +14,8 @@
from quickapp.agent.orchestrator import Orchestrator
from quickapp.common import DIAL_API_KEY, ForwardedHeaders, StagedBaseTool
from quickapp.common.abstract.base_prompt_provider import PromptPartProvider
-from quickapp.common.abstract.base_transformer import MessagesTransformer
+from quickapp.common.abstract.base_transformer import MessagesTransformer, PreInvocationTransformer
+from quickapp.common.abstract.completion_result_enricher import CompletionResultEnricher
from quickapp.common.dial_settings import DialSettings
from quickapp.common.state_holder import StateHolder
from quickapp.common.utils import sanitize_toolname
@@ -126,6 +127,17 @@ def provide_message_transformers(
add_system_prompt,
]
+ @multiprovider
+ def provide_pre_invocation_transformers(
+ self,
+ attachment_filter: _AttachmentFilter,
+ ) -> list[PreInvocationTransformer]:
+ return [attachment_filter]
+
+ @multiprovider
+ def provide_completion_result_enrichers(self) -> list[CompletionResultEnricher]:
+ return []
+
@multiprovider
def provide_prompt_parts(
self,
diff --git a/src/quickapp/agent/assistant_invoker.py b/src/quickapp/agent/assistant_invoker.py
index 8909519d..2759bea5 100644
--- a/src/quickapp/agent/assistant_invoker.py
+++ b/src/quickapp/agent/assistant_invoker.py
@@ -9,11 +9,11 @@
from openai.lib.azure import AsyncAzureOpenAI
from openai.types.chat import ChatCompletionChunk
-from quickapp.agent._attachment_filter import _AttachmentFilter
from quickapp.agent.agent_settings import AgentSettings
from quickapp.agent.message_logger import format_openai_message_pipe_tree
from quickapp.agent.models import STATE_KEY_ORCHESTRATOR, OpenAiToolConfigDict
from quickapp.common import RESPONSE_FORMAT, ForwardedHeaders
+from quickapp.common.abstract.base_transformer import PreInvocationTransformer
from quickapp.common.presentation_settings import PresentationSettings
from quickapp.config.application import ApplicationConfig
@@ -30,12 +30,12 @@ def __init__(
choice: Choice,
azure_client: AsyncAzureOpenAI,
response_format: RESPONSE_FORMAT,
- attachment_filter: _AttachmentFilter,
+ pre_invocation_transformers: list[PreInvocationTransformer],
presentation_settings: PresentationSettings,
agent_settings: AgentSettings,
forwarded_headers: ForwardedHeaders,
) -> None:
- self.__attachment_filter = attachment_filter
+ self.__pre_invocation_transformers = pre_invocation_transformers
self.__messages: list[Message] = messages
self.__choice: Choice = choice
self.__config: ApplicationConfig = config
@@ -106,9 +106,11 @@ def _log_messages(self, messages: list[Message]):
format_openai_message_pipe_tree(msg.dict(), idx, preview_len=preview_len)
def __prepare_messages(self, messages: list[Message]) -> list[dict[str, Any]]:
- filtered_messages = self.__attachment_filter.filter_attachments(messages)
+ transformed_messages = messages
+ for transformer in self.__pre_invocation_transformers:
+ transformed_messages = transformer.transform(transformed_messages)
result: list[dict[str, Any]] = []
- for message in filtered_messages:
+ for message in transformed_messages:
msg_dict = message.model_dump(exclude_none=True, mode="json")
self.__promote_orchestrator_state_to_top_level(msg_dict)
result.append(msg_dict)
diff --git a/src/quickapp/agent/tool_executor.py b/src/quickapp/agent/tool_executor.py
index 1406692b..c5c472c6 100644
--- a/src/quickapp/agent/tool_executor.py
+++ b/src/quickapp/agent/tool_executor.py
@@ -6,6 +6,7 @@
from quickapp.agent._models import AccumulatedToolCall
from quickapp.common import CompletionResult, StagedBaseTool
+from quickapp.common.abstract.completion_result_enricher import CompletionResultEnricher
from quickapp.common.perf_timer.perf_timer import PerformanceTimer
from quickapp.common.utils import sanitize_toolname
@@ -15,8 +16,14 @@
class ToolExecutor:
@inject
- def __init__(self, tools: list[StagedBaseTool], perf_timer: PerformanceTimer):
+ def __init__(
+ self,
+ tools: list[StagedBaseTool],
+ enrichers: list[CompletionResultEnricher],
+ perf_timer: PerformanceTimer,
+ ):
self.__tools: dict[str, StagedBaseTool] = self.__build_tool_dict(tools)
+ self.__enrichers = enrichers
self.__perf_timer: PerformanceTimer = perf_timer
self.__period_name = "tool_execution"
@@ -33,6 +40,10 @@ async def execute(self, tool_call_list: list[AccumulatedToolCall]) -> list[Compl
results = await asyncio.gather(*tasks, return_exceptions=False)
+ for enricher in self.__enrichers:
+ for result in results:
+ enricher.enrich(result)
+
return results
@staticmethod
diff --git a/src/quickapp/app_factory.py b/src/quickapp/app_factory.py
index 42eb2a74..ce2b5a9e 100644
--- a/src/quickapp/app_factory.py
+++ b/src/quickapp/app_factory.py
@@ -1,3 +1,5 @@
+import logging
+
from fastapi import FastAPI
from injector import Injector
@@ -17,6 +19,7 @@
from quickapp.rest_api_tooling import RestApiToolingModule
from quickapp.skills.skills_module import SkillsModule
from quickapp.starters.starters_module import StartersModule
+from quickapp.timestamp_tooling.timestamp_module import TimestampModule
class AppFactory:
@@ -46,8 +49,14 @@ def create() -> FastAPI:
FileTransferModule(),
AttachmentProcessingModule(),
SkillsModule(),
+ TimestampModule(),
]
- if not FeatureSettings().enable_preview_features:
+ if FeatureSettings().enable_preview_features:
+ logging.getLogger(__name__).info(
+ "Preview features are enabled (ENABLE_PREVIEW_FEATURES=true). "
+ "Preview modules and config fields are active."
+ )
+ else:
modules = [m for m in modules if not is_preview_module(m)]
injector = Injector(modules)
app = injector.get(FastAPI)
diff --git a/src/quickapp/attachment_processing/_tool_configs.py b/src/quickapp/attachment_processing/_tool_configs.py
index 5dc29a90..40ba85f1 100644
--- a/src/quickapp/attachment_processing/_tool_configs.py
+++ b/src/quickapp/attachment_processing/_tool_configs.py
@@ -5,10 +5,7 @@
OpenAiToolFunctionParameters,
)
from quickapp.config.tools.display.tool import ToolDisplayConfig, ToolStageConfig
-from quickapp.config.tools.internal import InternalTool
-
-INTERNAL_TOOL_NAME_PREFIX = "quickapps_internal_"
-
+from quickapp.config.tools.internal import INTERNAL_TOOL_NAME_PREFIX, InternalTool
AVAILABLE_CONTEXT_TOOL_CONFIG = InternalTool(
open_ai_tool=OpenAiToolConfig(
diff --git a/src/quickapp/common/abstract/base_transformer.py b/src/quickapp/common/abstract/base_transformer.py
index 317bfd64..a22aed9b 100644
--- a/src/quickapp/common/abstract/base_transformer.py
+++ b/src/quickapp/common/abstract/base_transformer.py
@@ -4,7 +4,23 @@
class MessagesTransformer(ABC):
- """Typed transformer that operates on a list of Messages with explicit ordering."""
+ """Runs once at request setup in _MessagesSetup.setup().
+
+ Mutates the canonical message list that persists across iterations.
+ """
+
+ @abstractmethod
+ def transform(self, messages: list[Message]) -> list[Message]: ...
+
+
+class PreInvocationTransformer(ABC):
+ """Runs before every LLM call in AssistantInvoker.__prepare_messages().
+
+ Each transformer is responsible for its own deep-copy strategy — it copies
+ only the messages it mutates, leaving the rest as references. Annotations
+ produced by these transformers only exist in the per-invocation copies and
+ are never persisted to the canonical message history.
+ """
@abstractmethod
def transform(self, messages: list[Message]) -> list[Message]: ...
diff --git a/src/quickapp/common/abstract/completion_result_enricher.py b/src/quickapp/common/abstract/completion_result_enricher.py
new file mode 100644
index 00000000..9142ceec
--- /dev/null
+++ b/src/quickapp/common/abstract/completion_result_enricher.py
@@ -0,0 +1,15 @@
+from abc import ABC, abstractmethod
+
+from quickapp.common.completion_result import CompletionResult
+
+
+class CompletionResultEnricher(ABC):
+ """Enriches a CompletionResult after tool execution.
+
+ Implementations are applied by ToolExecutor to every tool result.
+ Enrichers should use "fill if absent" semantics — if the result
+ already contains the metadata they would set, they should preserve it.
+ """
+
+ @abstractmethod
+ def enrich(self, result: CompletionResult) -> None: ...
diff --git a/src/quickapp/common/base_config.py b/src/quickapp/common/base_config.py
index 45b7c7c3..fb246e42 100644
--- a/src/quickapp/common/base_config.py
+++ b/src/quickapp/common/base_config.py
@@ -166,17 +166,26 @@ def _preview_field(default=None, **kwargs) -> FieldInfo:
Preview fields are stripped from the JSON schema and nullified at runtime
when ENABLE_PREVIEW_FEATURES is not set.
+ The field type must be ``T | None`` so the runtime can deactivate it by
+ setting the value to ``None``. Use either ``default=None`` (field is off
+ by default) or ``default_factory=SomeModel`` (field is on by default but
+ can still be nullified when preview features are disabled).
+
Args:
- default: Must be None (preview fields must be nullable).
- **kwargs: Other Pydantic Field parameters.
+ default: Must be None when no ``default_factory`` is provided.
+ **kwargs: Other Pydantic Field parameters (including ``default_factory``).
Returns:
Pydantic Field with preview marker.
"""
- if default is not None:
+ has_factory = "default_factory" in kwargs
+ if has_factory and default is not None:
+ raise TypeError("Cannot specify both default and default_factory for PreviewField.")
+ if default is not None and not has_factory:
raise TypeError(
"PreviewField requires default=None (preview fields must be nullable "
- "so they can be deactivated at runtime)."
+ "so they can be deactivated at runtime). "
+ "Use default_factory=... if the feature should be enabled by default."
)
json_schema_extra = kwargs.get("json_schema_extra", {})
if isinstance(json_schema_extra, dict):
@@ -191,6 +200,8 @@ def new_extra(schema):
json_schema_extra = new_extra
kwargs["json_schema_extra"] = json_schema_extra
+ if has_factory:
+ return Field(**kwargs)
return Field(default, **kwargs)
diff --git a/src/quickapp/common/message_metadata.py b/src/quickapp/common/message_metadata.py
new file mode 100644
index 00000000..1d2a5c21
--- /dev/null
+++ b/src/quickapp/common/message_metadata.py
@@ -0,0 +1,37 @@
+from datetime import datetime
+from enum import StrEnum
+from typing import Any
+
+from pydantic import BaseModel
+
+MESSAGE_METADATA_KEY = "_message_metadata"
+
+
+class TimestampSource(StrEnum):
+ DEFAULT = "default"
+ REQUEST = "request"
+
+
+class TimestampMetadata(BaseModel):
+ response_timestamp: datetime | None = None
+ timestamp_source: TimestampSource | None = None
+ timezone_name: str | None = None
+
+
+class MessageMetadata(BaseModel):
+ timestamp: TimestampMetadata | None = None
+
+
+def get_metadata_from_state(state: dict[str, Any] | None) -> MessageMetadata | None:
+ """Read MessageMetadata from a message/result state dict."""
+ if not state:
+ return None
+ raw = state.get(MESSAGE_METADATA_KEY)
+ if raw is None:
+ return None
+ return MessageMetadata.model_validate(raw)
+
+
+def set_metadata_in_state(state: dict[str, Any], metadata: MessageMetadata) -> None:
+ """Write MessageMetadata into a message/result state dict."""
+ state[MESSAGE_METADATA_KEY] = metadata.model_dump(mode="json")
diff --git a/src/quickapp/common/time_provider.py b/src/quickapp/common/time_provider.py
new file mode 100644
index 00000000..ff03408f
--- /dev/null
+++ b/src/quickapp/common/time_provider.py
@@ -0,0 +1,37 @@
+from datetime import datetime
+from zoneinfo import ZoneInfo
+
+from quickapp.common.message_metadata import TimestampSource
+
+
+class TimeProvider:
+ """Request-scoped provider that returns the current time in a configured timezone.
+
+ Calls ``datetime.now(tz)`` on each invocation — it is a provider, not a
+ snapshot. Each tool result gets stamped with its actual production time.
+ """
+
+ def __init__(
+ self, tz: ZoneInfo = ZoneInfo("UTC"), source: TimestampSource = TimestampSource.DEFAULT
+ ):
+ self._tz = tz
+ self._source = source
+
+ def now(self) -> datetime:
+ return datetime.now(self._tz)
+
+ @property
+ def tz(self) -> ZoneInfo:
+ return self._tz
+
+ @property
+ def tz_name(self) -> str:
+ return str(self._tz)
+
+ @property
+ def source(self) -> TimestampSource:
+ return self._source
+
+ def format_timestamp(self, dt: datetime) -> str:
+ tz_name = str(dt.tzinfo) if dt.tzinfo else self.tz_name
+ return f"{dt.isoformat()} ({tz_name}, source={self._source.value})"
diff --git a/src/quickapp/config/application.py b/src/quickapp/config/application.py
index d559eb5c..be14b693 100644
--- a/src/quickapp/config/application.py
+++ b/src/quickapp/config/application.py
@@ -3,12 +3,13 @@
from pydantic import BaseModel, Field, model_validator
from quickapp.agent.agent_settings import AgentSettings
-from quickapp.common.base_config import BaseApplicationTypeConfig, has_preview_marker
+from quickapp.common.base_config import BaseApplicationTypeConfig, PreviewField, has_preview_marker
from quickapp.common.feature_settings import FeatureSettings
from quickapp.config.context import Context
from quickapp.config.dial_deployment import DialDeploymentConfig
from quickapp.config.prompt import AgentSystemPromptConfig
from quickapp.config.starters import ConversationStartersConfig
+from quickapp.config.timestamp import TimestampConfig, ToolCallTimestampConfig
from quickapp.config.toolsets.toolset import ToolSet
logger = logging.getLogger(__name__)
@@ -54,6 +55,13 @@ def nullify_preview_fields(model: BaseModel) -> None:
nullify_preview_fields(value)
+class Features(BaseModel):
+ timestamp: TimestampConfig | None = PreviewField( # type: ignore[assignment]
+ default_factory=ToolCallTimestampConfig,
+ description="Time awareness configuration.",
+ )
+
+
class ApplicationConfig(BaseApplicationTypeConfig):
_dial_schema_id = "quickapps2"
_dial_application_type_display_name = "Quick App 2.0"
@@ -70,6 +78,10 @@ class ApplicationConfig(BaseApplicationTypeConfig):
conversation_starters: ConversationStartersConfig | None = Field(
description="The configuration for conversation starters.", default=None
)
+ features: Features | None = Field(
+ default_factory=Features,
+ description="QuickApps Agent features configuration.",
+ )
@model_validator(mode="after")
def _gate_preview_fields(self) -> "ApplicationConfig":
diff --git a/src/quickapp/config/timestamp.py b/src/quickapp/config/timestamp.py
new file mode 100644
index 00000000..0b30a3e6
--- /dev/null
+++ b/src/quickapp/config/timestamp.py
@@ -0,0 +1,16 @@
+from typing import Literal
+
+from pydantic import BaseModel
+
+
+class ToolCallTimestampConfig(BaseModel):
+ injection_strategy: Literal["tool_call"] = "tool_call"
+
+
+# Type alias — currently a single variant. When a second strategy is added
+# (e.g. SystemPromptTimestampConfig), change this to a discriminated union:
+# TimestampConfig = Annotated[
+# ToolCallTimestampConfig | SystemPromptTimestampConfig,
+# Discriminator("injection_strategy"),
+# ]
+TimestampConfig = ToolCallTimestampConfig
diff --git a/src/quickapp/config/tools/internal.py b/src/quickapp/config/tools/internal.py
index f5a149b0..b8bbcde2 100644
--- a/src/quickapp/config/tools/internal.py
+++ b/src/quickapp/config/tools/internal.py
@@ -4,6 +4,8 @@
from quickapp.config.tools.base import BaseOpenAITool
+INTERNAL_TOOL_NAME_PREFIX = "quickapps_internal_"
+
class InternalTool(BaseOpenAITool):
type: Literal["internal-tool"] = Field(default="internal-tool")
diff --git a/src/quickapp/timestamp_tooling/__init__.py b/src/quickapp/timestamp_tooling/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/quickapp/timestamp_tooling/_current_timestamp_stage_wrapper.py b/src/quickapp/timestamp_tooling/_current_timestamp_stage_wrapper.py
new file mode 100644
index 00000000..c0ec68b4
--- /dev/null
+++ b/src/quickapp/timestamp_tooling/_current_timestamp_stage_wrapper.py
@@ -0,0 +1,18 @@
+from typing import Any
+
+from injector import inject
+
+from quickapp.common import CompletionResult, TimedStageWrapper
+
+
+@inject
+class _CurrentTimestampStageWrapper(TimedStageWrapper):
+
+ def _get_formatted_parameters(self, parameters: dict[str, Any]) -> str:
+ return ""
+
+ def _build_debug_info_from_exception(self, exception: Exception) -> str:
+ return f"### Exception:\n\r{exception}\n\r"
+
+ def _build_debug_info_from_result(self, result: CompletionResult) -> str:
+ return f"### Current Timestamp:\n\r{result.content}\n\r"
diff --git a/src/quickapp/timestamp_tooling/_current_timestamp_tool.py b/src/quickapp/timestamp_tooling/_current_timestamp_tool.py
new file mode 100644
index 00000000..fab38cf0
--- /dev/null
+++ b/src/quickapp/timestamp_tooling/_current_timestamp_tool.py
@@ -0,0 +1,79 @@
+from typing import Any
+from zoneinfo import ZoneInfo
+
+from injector import AssistedBuilder, inject
+
+from quickapp.common import CompletionResult, StagedBaseTool
+from quickapp.common.abstract.base_tool_argument_transformer import ToolArgumentTransformer
+from quickapp.common.base_stage_wrapper import BaseStageWrapper
+from quickapp.common.message_metadata import (
+ MessageMetadata,
+ TimestampMetadata,
+ TimestampSource,
+ set_metadata_in_state,
+)
+from quickapp.common.perf_timer.perf_timer import PerformanceTimer
+from quickapp.common.time_provider import TimeProvider
+from quickapp.config.tools.internal import InternalTool
+from quickapp.timestamp_tooling._current_timestamp_stage_wrapper import (
+ _CurrentTimestampStageWrapper,
+)
+
+
+@inject
+class _CurrentTimestampTool(StagedBaseTool):
+
+ def __init__(
+ self,
+ stage_wrapper_builder: AssistedBuilder[_CurrentTimestampStageWrapper],
+ tool_config: InternalTool,
+ perf_timer: PerformanceTimer,
+ time_provider: TimeProvider,
+ argument_transformers: list[ToolArgumentTransformer] | None = None,
+ **kwargs: Any,
+ ):
+ super().__init__(
+ stage_wrapper_builder=stage_wrapper_builder, # type: ignore[arg-type]
+ tool_config=tool_config,
+ perf_timer=perf_timer,
+ argument_transformers=argument_transformers,
+ **kwargs,
+ )
+ self.__time_provider = time_provider
+
+ async def _run_in_stage_async(
+ self,
+ stage_wrapper: BaseStageWrapper | None = None,
+ *args: Any,
+ **kwargs: Any,
+ ) -> CompletionResult:
+ timezone_str: str | None = kwargs.get("timezone")
+
+ if timezone_str is not None:
+ tz = ZoneInfo(timezone_str)
+ source = TimestampSource.REQUEST
+ else:
+ tz = self.__time_provider.tz
+ source = self.__time_provider.source
+
+ now = self.__time_provider.now().astimezone(tz)
+ tz_name = str(tz)
+
+ content = f"{now.isoformat()} ({tz_name}, source={source.value})"
+
+ state: dict[str, Any] = {}
+ set_metadata_in_state(
+ state,
+ MessageMetadata(
+ timestamp=TimestampMetadata(
+ response_timestamp=now,
+ timestamp_source=source,
+ timezone_name=tz_name,
+ )
+ ),
+ )
+
+ result = CompletionResult(content=content, content_type="text/plain", state=state)
+ if stage_wrapper:
+ stage_wrapper.add_result(result)
+ return result
diff --git a/src/quickapp/timestamp_tooling/_timestamp_annotation_transformer.py b/src/quickapp/timestamp_tooling/_timestamp_annotation_transformer.py
new file mode 100644
index 00000000..5051c566
--- /dev/null
+++ b/src/quickapp/timestamp_tooling/_timestamp_annotation_transformer.py
@@ -0,0 +1,46 @@
+import copy
+
+from aidial_sdk.chat_completion import Message, Role
+
+from quickapp.common.abstract.base_transformer import PreInvocationTransformer
+from quickapp.common.message_metadata import get_metadata_from_state
+from quickapp.timestamp_tooling._tool_configs import SYNTHETIC_TIMESTAMP_CALL_PREFIX
+
+
+class _TimestampAnnotationTransformer(PreInvocationTransformer):
+ """Annotates tool messages with human-readable timestamps before each LLM
+ call.
+
+ Reads ``_message_metadata`` from ``custom_content.state`` and appends a
+ ``[Timestamp: ...]`` line to the message content. Skips synthetic
+ timestamp messages (identified by the call-ID prefix) to avoid
+ double-annotating.
+
+ Uses selective deep copies — only messages that are mutated are copied.
+ """
+
+ def transform(self, messages: list[Message]) -> list[Message]:
+ result: list[Message] = []
+ for msg in messages:
+ if msg.role == Role.TOOL and msg.tool_call_id:
+ if msg.tool_call_id.startswith(SYNTHETIC_TIMESTAMP_CALL_PREFIX):
+ result.append(msg)
+ continue
+
+ state = msg.custom_content.state if msg.custom_content else None
+ metadata = get_metadata_from_state(state)
+ if metadata and metadata.timestamp and metadata.timestamp.response_timestamp:
+ ts = metadata.timestamp
+ response_ts = ts.response_timestamp
+ assert response_ts is not None
+ annotation = (
+ f"\n[Timestamp: {response_ts.strftime('%Y-%m-%d %H:%M:%S')}"
+ f" {ts.timezone_name or 'UTC'}]"
+ )
+ if not isinstance(msg.content, str):
+ result.append(msg)
+ continue
+ msg = copy.deepcopy(msg)
+ msg.content = str(msg.content or "") + annotation
+ result.append(msg)
+ return result
diff --git a/src/quickapp/timestamp_tooling/_timestamp_injection_transformer.py b/src/quickapp/timestamp_tooling/_timestamp_injection_transformer.py
new file mode 100644
index 00000000..1b92f753
--- /dev/null
+++ b/src/quickapp/timestamp_tooling/_timestamp_injection_transformer.py
@@ -0,0 +1,63 @@
+import uuid
+
+from aidial_sdk.chat_completion import Message, Role
+from aidial_sdk.chat_completion.request import FunctionCall, ToolCall
+from injector import ProviderOf, inject
+
+from quickapp.common.abstract.base_transformer import MessagesTransformer
+from quickapp.common.time_provider import TimeProvider
+from quickapp.config.application import ApplicationConfig
+from quickapp.timestamp_tooling._tool_configs import (
+ CURRENT_TIMESTAMP_TOOL_NAME,
+ SYNTHETIC_TIMESTAMP_CALL_PREFIX,
+)
+
+
+class _TimestampInjectionTransformer(MessagesTransformer):
+ """Appends a synthetic tool-call + tool-result pair with the current
+ timestamp at the end of the message list.
+
+ Runs once at request setup via ``_MessagesSetup``. Historical timestamps
+ are restored from state by ``extract_tool_calls()`` with their original
+ times; this transformer only appends the *current* turn's timestamp.
+ """
+
+ @inject
+ def __init__(
+ self,
+ time_provider: TimeProvider,
+ config_provider: ProviderOf[ApplicationConfig],
+ ):
+ self.__time_provider = time_provider
+ self.__config_provider = config_provider
+
+ def transform(self, messages: list[Message]) -> list[Message]:
+ features = self.__config_provider.get().features
+ if features is None or features.timestamp is None or not messages:
+ return messages
+
+ now = self.__time_provider.now()
+ content = self.__time_provider.format_timestamp(now)
+ call_id = f"{SYNTHETIC_TIMESTAMP_CALL_PREFIX}{uuid.uuid4().hex[:12]}"
+
+ assistant_msg = Message(
+ role=Role.ASSISTANT,
+ content="",
+ tool_calls=[
+ ToolCall(
+ id=call_id,
+ type="function",
+ function=FunctionCall(
+ name=CURRENT_TIMESTAMP_TOOL_NAME,
+ arguments="{}",
+ ),
+ )
+ ],
+ )
+ tool_msg = Message(
+ role=Role.TOOL,
+ content=content,
+ tool_call_id=call_id,
+ )
+
+ return messages + [assistant_msg, tool_msg]
diff --git a/src/quickapp/timestamp_tooling/_timestamp_metadata_enricher.py b/src/quickapp/timestamp_tooling/_timestamp_metadata_enricher.py
new file mode 100644
index 00000000..671a7451
--- /dev/null
+++ b/src/quickapp/timestamp_tooling/_timestamp_metadata_enricher.py
@@ -0,0 +1,37 @@
+from quickapp.common.abstract.completion_result_enricher import CompletionResultEnricher
+from quickapp.common.completion_result import CompletionResult
+from quickapp.common.message_metadata import (
+ MessageMetadata,
+ TimestampMetadata,
+ get_metadata_from_state,
+ set_metadata_in_state,
+)
+from quickapp.common.time_provider import TimeProvider
+
+
+class _TimestampMetadataEnricher(CompletionResultEnricher):
+ """Stamps every tool result with its production timestamp.
+
+ Uses "fill if absent" semantics — if the result already carries
+ timestamp metadata (e.g. set by ``_CurrentTimestampTool``), it is
+ preserved.
+ """
+
+ def __init__(self, time_provider: TimeProvider):
+ self.__time_provider = time_provider
+
+ def enrich(self, result: CompletionResult) -> None:
+ if result.state is None:
+ result.state = {}
+
+ existing = get_metadata_from_state(result.state)
+ if existing and existing.timestamp and existing.timestamp.response_timestamp:
+ return
+
+ metadata = existing or MessageMetadata()
+ metadata.timestamp = TimestampMetadata(
+ response_timestamp=self.__time_provider.now(),
+ timestamp_source=self.__time_provider.source,
+ timezone_name=self.__time_provider.tz_name,
+ )
+ set_metadata_in_state(result.state, metadata)
diff --git a/src/quickapp/timestamp_tooling/_tool_configs.py b/src/quickapp/timestamp_tooling/_tool_configs.py
new file mode 100644
index 00000000..e6b254ba
--- /dev/null
+++ b/src/quickapp/timestamp_tooling/_tool_configs.py
@@ -0,0 +1,33 @@
+from quickapp.config.tools.base import (
+ ConfigurableSchemaSimpleType,
+ JsonTypeEnum,
+ OpenAiToolConfig,
+ OpenAiToolFunction,
+ OpenAiToolFunctionParameters,
+)
+from quickapp.config.tools.display.tool import ToolDisplayConfig, ToolStageConfig
+from quickapp.config.tools.internal import INTERNAL_TOOL_NAME_PREFIX, InternalTool
+
+SYNTHETIC_TIMESTAMP_CALL_PREFIX = "call_synthetic_timestamp_"
+
+CURRENT_TIMESTAMP_TOOL_CONFIG = InternalTool(
+ open_ai_tool=OpenAiToolConfig(
+ function=OpenAiToolFunction(
+ name=f"{INTERNAL_TOOL_NAME_PREFIX}current_timestamp",
+ description="Returns the current date and time. Optionally converts to a specific timezone.",
+ parameters=OpenAiToolFunctionParameters(
+ type=JsonTypeEnum.object,
+ properties={
+ "timezone": ConfigurableSchemaSimpleType(
+ type=JsonTypeEnum.string,
+ description="IANA timezone name (e.g. 'Asia/Tokyo'). Defaults to UTC.",
+ )
+ },
+ ),
+ )
+ ),
+ display=ToolDisplayConfig(stage=ToolStageConfig(name="Current timestamp")),
+)
+
+# Tool name after hashing by OpenAiToolFunction.set_name validator
+CURRENT_TIMESTAMP_TOOL_NAME = CURRENT_TIMESTAMP_TOOL_CONFIG.open_ai_tool.function.name
diff --git a/src/quickapp/timestamp_tooling/timestamp_module.py b/src/quickapp/timestamp_tooling/timestamp_module.py
new file mode 100644
index 00000000..2206bd63
--- /dev/null
+++ b/src/quickapp/timestamp_tooling/timestamp_module.py
@@ -0,0 +1,94 @@
+import logging
+
+from fastapi_injector import request_scope
+from injector import AssistedBuilder, Binder, Module, multiprovider
+
+from quickapp.common import StagedBaseTool
+from quickapp.common.abstract.base_transformer import MessagesTransformer, PreInvocationTransformer
+from quickapp.common.abstract.completion_result_enricher import CompletionResultEnricher
+from quickapp.common.preview import preview_module
+from quickapp.common.time_provider import TimeProvider
+from quickapp.config.application import ApplicationConfig
+from quickapp.timestamp_tooling._current_timestamp_tool import _CurrentTimestampTool
+from quickapp.timestamp_tooling._timestamp_annotation_transformer import (
+ _TimestampAnnotationTransformer,
+)
+from quickapp.timestamp_tooling._timestamp_injection_transformer import (
+ _TimestampInjectionTransformer,
+)
+from quickapp.timestamp_tooling._timestamp_metadata_enricher import _TimestampMetadataEnricher
+from quickapp.timestamp_tooling._tool_configs import CURRENT_TIMESTAMP_TOOL_CONFIG
+
+logger = logging.getLogger(__name__)
+
+
+@preview_module
+class TimestampModule(Module):
+
+ def configure(self, binder: Binder) -> None:
+ binder.bind(TimeProvider, to=TimeProvider, scope=request_scope)
+ binder.bind(_CurrentTimestampTool, to=_CurrentTimestampTool, scope=request_scope)
+ binder.bind(
+ _TimestampInjectionTransformer,
+ to=_TimestampInjectionTransformer,
+ scope=request_scope,
+ )
+ binder.bind(
+ _TimestampAnnotationTransformer,
+ to=_TimestampAnnotationTransformer,
+ scope=request_scope,
+ )
+
+ logger.debug("TimestampModule configuration completed")
+
+ @staticmethod
+ def _is_timestamp_feature_enabled(app_config: ApplicationConfig) -> bool:
+ features = app_config.features
+ return features is not None and features.timestamp is not None
+
+ @multiprovider
+ def _provide_timestamp_tools(
+ self,
+ app_config: ApplicationConfig,
+ tool_builder: AssistedBuilder[_CurrentTimestampTool],
+ ) -> list[StagedBaseTool]:
+ if not self._is_timestamp_feature_enabled(app_config):
+ return []
+
+ return [
+ tool_builder.build(
+ tool_config=CURRENT_TIMESTAMP_TOOL_CONFIG,
+ name=CURRENT_TIMESTAMP_TOOL_CONFIG.open_ai_tool.function.name,
+ description=CURRENT_TIMESTAMP_TOOL_CONFIG.open_ai_tool.function.description,
+ )
+ ]
+
+ # Always registered — the transformer self-gates via ProviderOf[ApplicationConfig]
+ # because list[MessagesTransformer] is resolved during _RequestContextSetup
+ # construction, before ApplicationConfig is available.
+ @multiprovider
+ def _provide_message_transformers(
+ self,
+ injection_transformer: _TimestampInjectionTransformer,
+ ) -> list[MessagesTransformer]:
+ return [injection_transformer]
+
+ @multiprovider
+ def _provide_pre_invocation_transformers(
+ self,
+ app_config: ApplicationConfig,
+ annotation_transformer: _TimestampAnnotationTransformer,
+ ) -> list[PreInvocationTransformer]:
+ if not self._is_timestamp_feature_enabled(app_config):
+ return []
+ return [annotation_transformer]
+
+ @multiprovider
+ def _provide_enrichers(
+ self,
+ app_config: ApplicationConfig,
+ time_provider: TimeProvider,
+ ) -> list[CompletionResultEnricher]:
+ if not self._is_timestamp_feature_enabled(app_config):
+ return []
+ return [_TimestampMetadataEnricher(time_provider)]
diff --git a/src/tests/unit_tests/agent_tests/test_assistant_invoker.py b/src/tests/unit_tests/agent_tests/test_assistant_invoker.py
index fe2131ad..bbefc98c 100644
--- a/src/tests/unit_tests/agent_tests/test_assistant_invoker.py
+++ b/src/tests/unit_tests/agent_tests/test_assistant_invoker.py
@@ -49,7 +49,7 @@ def __init__(self):
mock_filter = Mock()
-mock_filter.filter_attachments.side_effect = lambda messages: messages
+mock_filter.transform.side_effect = lambda messages: messages
@pytest.mark.asyncio
@@ -68,7 +68,7 @@ async def test_invoke_without_show_usage(monkeypatch):
choice=SimpleNamespace(), # not used in code under test
azure_client=azure_client,
response_format=None,
- attachment_filter=mock_filter,
+ pre_invocation_transformers=[mock_filter],
presentation_settings=_presentation_settings(False),
agent_settings=_agent_settings(),
forwarded_headers=None,
@@ -106,7 +106,7 @@ async def test_invoke_with_show_usage_true(monkeypatch):
messages=[FakeMessage("hello2")],
choice=SimpleNamespace(),
azure_client=azure_client,
- attachment_filter=mock_filter,
+ pre_invocation_transformers=[mock_filter],
response_format=None,
presentation_settings=_presentation_settings(True),
agent_settings=_agent_settings(),
@@ -141,7 +141,7 @@ async def test_invoke_propagates_exceptions():
messages=[FakeMessage("oops")],
choice=SimpleNamespace(),
azure_client=azure_client,
- attachment_filter=mock_filter,
+ pre_invocation_transformers=[mock_filter],
response_format=None,
presentation_settings=_presentation_settings(False),
agent_settings=_agent_settings(),
diff --git a/src/tests/unit_tests/agent_tests/test_attachment_filter.py b/src/tests/unit_tests/agent_tests/test_attachment_filter.py
index 54fee20d..91cc1169 100644
--- a/src/tests/unit_tests/agent_tests/test_attachment_filter.py
+++ b/src/tests/unit_tests/agent_tests/test_attachment_filter.py
@@ -37,7 +37,7 @@ def test_image_attachments_kept_inline(self):
"look at this",
[_attachment("photo.png", "/files/photo.png", "image/png")],
)
- result = transformer.filter_attachments([msg])
+ result = transformer.transform([msg])
assert len(result[0].custom_content.attachments) == 1
assert result[0].custom_content.attachments[0].type == "image/png"
@@ -47,7 +47,7 @@ def test_non_image_attachments_removed(self):
"check this",
[_attachment("doc.pdf", "/files/doc.pdf", "application/pdf")],
)
- result = transformer.filter_attachments([msg])
+ result = transformer.transform([msg])
assert len(result[0].custom_content.attachments) == 0
def test_xml_metadata_injected_for_attachments(self):
@@ -59,7 +59,7 @@ def test_xml_metadata_injected_for_attachments(self):
_attachment("photo.png", "/files/photo.png", "image/png"),
],
)
- result = transformer.filter_attachments([msg])
+ result = transformer.transform([msg])
content = str(result[0].content)
assert "" in content
assert "doc.pdf" in content
@@ -82,7 +82,7 @@ def test_mixed_attachments_only_images_kept(self):
_attachment("chart.jpg", "/files/chart.jpg", "image/jpeg"),
],
)
- result = transformer.filter_attachments([msg])
+ result = transformer.transform([msg])
attachments = result[0].custom_content.attachments
assert len(attachments) == 2
types = {str(a.type) for a in attachments}
@@ -97,13 +97,13 @@ def test_filter_does_not_mutate_original_messages(self):
original_content = str(msg.content)
original_attachment_count = len(msg.custom_content.attachments)
- transformer.filter_attachments([msg])
+ transformer.transform([msg])
assert str(msg.content) == original_content
assert len(msg.custom_content.attachments) == original_attachment_count
def test_filter_idempotent_on_repeated_calls(self):
- """Calling filter_attachments twice on the same list produces identical output."""
+ """Calling transform twice on the same list produces identical output."""
transformer = _AttachmentFilter()
msg = _user_msg(
"hello",
@@ -111,10 +111,10 @@ def test_filter_idempotent_on_repeated_calls(self):
)
messages = [msg]
- first_pass = transformer.filter_attachments(messages)
+ first_pass = transformer.transform(messages)
first_content = str(first_pass[0].content)
- second_pass = transformer.filter_attachments(messages)
+ second_pass = transformer.transform(messages)
second_content = str(second_pass[0].content)
assert first_content == second_content
@@ -135,7 +135,7 @@ def test_multi_message_each_filtered_independently(self):
"second",
[_attachment("data.csv", "/files/data.csv", "text/csv")],
)
- result = transformer.filter_attachments([msg1, msg2])
+ result = transformer.transform([msg1, msg2])
# First message: image kept, pdf removed
assert len(result[0].custom_content.attachments) == 1
@@ -156,7 +156,7 @@ def test_multi_message_non_attachment_messages_unchanged(self):
"with file",
[_attachment("doc.pdf", "/files/doc.pdf", "application/pdf")],
)
- result = transformer.filter_attachments([plain_msg, attach_msg])
+ result = transformer.transform([plain_msg, attach_msg])
# Plain message is passed through as-is (same object, no deepcopy)
assert result[0] is plain_msg
@@ -174,7 +174,7 @@ def test_assistant_message_image_attachments_stripped(self):
"response",
[_attachment("photo.png", "/files/photo.png", "image/png")],
)
- result = transformer.filter_attachments([msg])
+ result = transformer.transform([msg])
# Non-USER roles: images are NOT kept inline
assert len(result[0].custom_content.attachments) == 0
content = str(result[0].content)
@@ -187,7 +187,7 @@ def test_tool_message_attachments_stripped(self):
"tool output",
[_attachment("result.png", "/files/result.png", "image/png")],
)
- result = transformer.filter_attachments([msg])
+ result = transformer.transform([msg])
assert len(result[0].custom_content.attachments) == 0
content = str(result[0].content)
assert "result.png" in content
@@ -196,7 +196,7 @@ def test_tool_message_attachments_stripped(self):
def test_empty_message_list(self):
transformer = _AttachmentFilter()
- result = transformer.filter_attachments([])
+ result = transformer.transform([])
assert result == []
def test_content_none_with_attachments(self):
@@ -206,7 +206,7 @@ def test_content_none_with_attachments(self):
None,
[_attachment("doc.pdf", "/files/doc.pdf", "application/pdf")],
)
- result = transformer.filter_attachments([msg])
+ result = transformer.transform([msg])
content = str(result[0].content)
assert "" in content
assert "doc.pdf" in content
@@ -217,7 +217,7 @@ def test_reference_url_conditional_absent(self):
"test",
[_attachment("doc.pdf", "/files/doc.pdf", "application/pdf")],
)
- result = transformer.filter_attachments([msg])
+ result = transformer.transform([msg])
content = str(result[0].content)
# reference_url is None by default → no element
assert "" not in content
@@ -235,6 +235,6 @@ def test_reference_url_conditional_present(self):
)
],
)
- result = transformer.filter_attachments([msg])
+ result = transformer.transform([msg])
content = str(result[0].content)
assert "/refs/doc.pdf" in content
diff --git a/src/tests/unit_tests/common/test_message_metadata.py b/src/tests/unit_tests/common/test_message_metadata.py
new file mode 100644
index 00000000..7bab6b1f
--- /dev/null
+++ b/src/tests/unit_tests/common/test_message_metadata.py
@@ -0,0 +1,44 @@
+from datetime import datetime, timezone
+
+from quickapp.common.message_metadata import (
+ MESSAGE_METADATA_KEY,
+ MessageMetadata,
+ TimestampMetadata,
+ TimestampSource,
+ get_metadata_from_state,
+ set_metadata_in_state,
+)
+
+
+class TestMessageMetadata:
+ def test_round_trip(self):
+ metadata = MessageMetadata(
+ timestamp=TimestampMetadata(
+ response_timestamp=datetime(2026, 1, 15, 12, 30, 0, tzinfo=timezone.utc),
+ timestamp_source=TimestampSource.DEFAULT,
+ timezone_name="UTC",
+ )
+ )
+ state: dict = {}
+ set_metadata_in_state(state, metadata)
+
+ restored = get_metadata_from_state(state)
+ assert restored is not None
+ assert restored.timestamp is not None
+ assert restored.timestamp.response_timestamp == metadata.timestamp.response_timestamp
+ assert restored.timestamp.timestamp_source == TimestampSource.DEFAULT
+ assert restored.timestamp.timezone_name == "UTC"
+
+ def test_get_from_none_state(self):
+ assert get_metadata_from_state(None) is None
+
+ def test_get_from_empty_state(self):
+ assert get_metadata_from_state({}) is None
+
+ def test_get_from_state_without_key(self):
+ assert get_metadata_from_state({"other": "value"}) is None
+
+ def test_set_creates_key(self):
+ state: dict = {}
+ set_metadata_in_state(state, MessageMetadata())
+ assert MESSAGE_METADATA_KEY in state
diff --git a/src/tests/unit_tests/common/test_preview_field.py b/src/tests/unit_tests/common/test_preview_field.py
index 5ca1bb5d..0eae687b 100644
--- a/src/tests/unit_tests/common/test_preview_field.py
+++ b/src/tests/unit_tests/common/test_preview_field.py
@@ -45,6 +45,27 @@ class M(BaseModel):
assert feat_schema.get(_PREVIEW_MARKER) is True
assert feat_schema.get("custom") is True
+ def test_default_factory_accepted(self):
+ class Inner(BaseModel):
+ x: int = 1
+
+ class M(BaseModel):
+ feat: Inner | None = PreviewField(default_factory=Inner)
+
+ instance = M()
+ assert instance.feat is not None
+ assert instance.feat.x == 1
+
+ def test_default_factory_has_preview_marker(self):
+ class Inner(BaseModel):
+ x: int = 1
+
+ class M(BaseModel):
+ feat: Inner | None = PreviewField(default_factory=Inner)
+
+ field_info = M.model_fields["feat"]
+ assert has_preview_marker(field_info) is True
+
def test_with_description(self):
class M(BaseModel):
feat: str | None = PreviewField(description="A preview feature")
diff --git a/src/tests/unit_tests/common/test_time_provider.py b/src/tests/unit_tests/common/test_time_provider.py
new file mode 100644
index 00000000..25dc032d
--- /dev/null
+++ b/src/tests/unit_tests/common/test_time_provider.py
@@ -0,0 +1,51 @@
+from datetime import datetime, timezone
+from zoneinfo import ZoneInfo
+
+from quickapp.common.message_metadata import TimestampSource
+from quickapp.common.time_provider import TimeProvider
+
+
+class TestTimeProvider:
+ def test_now_returns_utc_by_default(self):
+ provider = TimeProvider()
+ result = provider.now()
+ assert result.tzinfo is not None
+ assert str(result.tzinfo) == "UTC"
+
+ def test_now_returns_configured_timezone(self):
+ provider = TimeProvider(tz=ZoneInfo("Asia/Tokyo"))
+ result = provider.now()
+ assert str(result.tzinfo) == "Asia/Tokyo"
+
+ def test_tz_name_property(self):
+ provider = TimeProvider(tz=ZoneInfo("Europe/Berlin"))
+ assert provider.tz_name == "Europe/Berlin"
+
+ def test_source_defaults_to_default(self):
+ provider = TimeProvider()
+ assert provider.source == TimestampSource.DEFAULT
+
+ def test_source_can_be_request(self):
+ provider = TimeProvider(source=TimestampSource.REQUEST)
+ assert provider.source == TimestampSource.REQUEST
+
+ def test_now_is_provider_not_snapshot(self):
+ provider = TimeProvider()
+ t1 = provider.now()
+ t2 = provider.now()
+ assert t2 >= t1
+
+ def test_format_timestamp_uses_provider_defaults(self):
+ provider = TimeProvider()
+ dt = datetime(2026, 3, 24, 14, 30, 0, tzinfo=timezone.utc)
+ result = provider.format_timestamp(dt)
+ assert "2026-03-24T14:30:00+00:00" in result
+ assert "UTC" in result
+ assert "source=default" in result
+
+ def test_format_timestamp_uses_datetime_timezone(self):
+ provider = TimeProvider()
+ tokyo = ZoneInfo("Asia/Tokyo")
+ dt = datetime(2026, 3, 24, 14, 30, 0, tzinfo=tokyo)
+ result = provider.format_timestamp(dt)
+ assert "Asia/Tokyo" in result
diff --git a/src/tests/unit_tests/config_tests/test_features_config.py b/src/tests/unit_tests/config_tests/test_features_config.py
new file mode 100644
index 00000000..31c05072
--- /dev/null
+++ b/src/tests/unit_tests/config_tests/test_features_config.py
@@ -0,0 +1,34 @@
+from quickapp.config.application import Features
+from quickapp.config.timestamp import ToolCallTimestampConfig
+
+
+class TestFeaturesConfig:
+ def test_default_features_has_timestamp_enabled(self, monkeypatch):
+ monkeypatch.setenv("ENABLE_PREVIEW_FEATURES", "true")
+ features = Features()
+ assert features.timestamp is not None
+ assert isinstance(features.timestamp, ToolCallTimestampConfig)
+
+ def test_timestamp_none_disables(self, monkeypatch):
+ monkeypatch.setenv("ENABLE_PREVIEW_FEATURES", "true")
+ features = Features(timestamp=None)
+ assert features.timestamp is None
+
+ def test_explicit_timestamp_config(self, monkeypatch):
+ monkeypatch.setenv("ENABLE_PREVIEW_FEATURES", "true")
+ features = Features(timestamp=ToolCallTimestampConfig())
+ assert features.timestamp is not None
+ assert features.timestamp.injection_strategy == "tool_call"
+
+ def test_empty_dict_creates_default(self, monkeypatch):
+ monkeypatch.setenv("ENABLE_PREVIEW_FEATURES", "true")
+ features = Features.model_validate({})
+ assert features.timestamp is not None
+
+ def test_preview_gating_nullifies_timestamp(self, monkeypatch):
+ monkeypatch.delenv("ENABLE_PREVIEW_FEATURES", raising=False)
+ from quickapp.config.application import nullify_preview_fields
+
+ features = Features()
+ nullify_preview_fields(features)
+ assert features.timestamp is None
diff --git a/src/tests/unit_tests/timestamp_tooling/__init__.py b/src/tests/unit_tests/timestamp_tooling/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/tests/unit_tests/timestamp_tooling/test_current_timestamp_tool.py b/src/tests/unit_tests/timestamp_tooling/test_current_timestamp_tool.py
new file mode 100644
index 00000000..fb1ff0ad
--- /dev/null
+++ b/src/tests/unit_tests/timestamp_tooling/test_current_timestamp_tool.py
@@ -0,0 +1,66 @@
+from unittest.mock import MagicMock
+
+import pytest
+
+from quickapp.common.time_provider import TimeProvider
+from quickapp.timestamp_tooling._current_timestamp_tool import _CurrentTimestampTool
+from quickapp.timestamp_tooling._tool_configs import CURRENT_TIMESTAMP_TOOL_CONFIG
+
+
+def _build_tool(time_provider: TimeProvider | None = None) -> _CurrentTimestampTool:
+ tp = time_provider or TimeProvider()
+ tool = _CurrentTimestampTool(
+ stage_wrapper_builder=MagicMock(),
+ tool_config=CURRENT_TIMESTAMP_TOOL_CONFIG,
+ perf_timer=MagicMock(),
+ time_provider=tp,
+ )
+ return tool
+
+
+class TestCurrentTimestampTool:
+ @pytest.mark.asyncio
+ async def test_returns_utc_by_default(self):
+ tool = _build_tool()
+ result = await tool._run_in_stage_async(stage_wrapper=None)
+
+ assert "UTC" in result.content
+ assert "source=default" in result.content
+ assert "T" in result.content # ISO 8601
+
+ @pytest.mark.asyncio
+ async def test_timezone_conversion(self):
+ tool = _build_tool()
+ result = await tool._run_in_stage_async(stage_wrapper=None, timezone="Asia/Tokyo")
+
+ assert "Asia/Tokyo" in result.content
+ assert "source=request" in result.content
+
+ @pytest.mark.asyncio
+ async def test_invalid_timezone_raises(self):
+ tool = _build_tool()
+ with pytest.raises(KeyError):
+ await tool._run_in_stage_async(stage_wrapper=None, timezone="Invalid/Timezone")
+
+ @pytest.mark.asyncio
+ async def test_result_has_state_with_metadata(self):
+ tool = _build_tool()
+ result = await tool._run_in_stage_async(stage_wrapper=None)
+
+ assert result.state is not None
+ assert "_message_metadata" in result.state
+
+ @pytest.mark.asyncio
+ async def test_content_type_is_text_plain(self):
+ tool = _build_tool()
+ result = await tool._run_in_stage_async(stage_wrapper=None)
+
+ assert result.content_type == "text/plain"
+
+ @pytest.mark.asyncio
+ async def test_stage_wrapper_receives_result(self):
+ tool = _build_tool()
+ mock_wrapper = MagicMock()
+ result = await tool._run_in_stage_async(stage_wrapper=mock_wrapper)
+
+ mock_wrapper.add_result.assert_called_once_with(result)
diff --git a/src/tests/unit_tests/timestamp_tooling/test_timestamp_annotation_transformer.py b/src/tests/unit_tests/timestamp_tooling/test_timestamp_annotation_transformer.py
new file mode 100644
index 00000000..cba5e9c9
--- /dev/null
+++ b/src/tests/unit_tests/timestamp_tooling/test_timestamp_annotation_transformer.py
@@ -0,0 +1,107 @@
+from datetime import datetime, timezone
+
+from aidial_sdk.chat_completion import CustomContent, Message, Role
+
+from quickapp.common.message_metadata import (
+ MESSAGE_METADATA_KEY,
+ MessageMetadata,
+ TimestampMetadata,
+ TimestampSource,
+)
+from quickapp.timestamp_tooling._timestamp_annotation_transformer import (
+ _TimestampAnnotationTransformer,
+)
+from quickapp.timestamp_tooling._tool_configs import SYNTHETIC_TIMESTAMP_CALL_PREFIX
+
+
+def _tool_msg_with_metadata(
+ tool_call_id: str = "call_123",
+ content: str = '{"data": 42}',
+ ts: datetime | None = None,
+) -> Message:
+ ts = ts or datetime(2026, 1, 15, 12, 30, 0, tzinfo=timezone.utc)
+ metadata = MessageMetadata(
+ timestamp=TimestampMetadata(
+ response_timestamp=ts,
+ timestamp_source=TimestampSource.DEFAULT,
+ timezone_name="UTC",
+ )
+ )
+ return Message(
+ role=Role.TOOL,
+ content=content,
+ tool_call_id=tool_call_id,
+ custom_content=CustomContent(
+ state={MESSAGE_METADATA_KEY: metadata.model_dump(mode="json")}
+ ),
+ )
+
+
+class TestTimestampAnnotationTransformer:
+ def test_annotates_tool_message_with_timestamp(self):
+ transformer = _TimestampAnnotationTransformer()
+ msg = _tool_msg_with_metadata()
+ result = transformer.transform([msg])
+
+ assert len(result) == 1
+ content = str(result[0].content)
+ assert "[Timestamp: 2026-01-15 12:30:00 UTC]" in content
+ assert '{"data": 42}' in content
+
+ def test_skips_synthetic_timestamp_messages(self):
+ transformer = _TimestampAnnotationTransformer()
+ msg = _tool_msg_with_metadata(tool_call_id=f"{SYNTHETIC_TIMESTAMP_CALL_PREFIX}abc123")
+ result = transformer.transform([msg])
+
+ content = str(result[0].content)
+ assert "[Timestamp:" not in content
+
+ def test_skips_tool_messages_without_metadata(self):
+ transformer = _TimestampAnnotationTransformer()
+ msg = Message(role=Role.TOOL, content="no metadata", tool_call_id="call_456")
+ result = transformer.transform([msg])
+
+ assert str(result[0].content) == "no metadata"
+
+ def test_passes_non_tool_messages_unchanged(self):
+ transformer = _TimestampAnnotationTransformer()
+ user_msg = Message(role=Role.USER, content="hello")
+ assistant_msg = Message(role=Role.ASSISTANT, content="world")
+ result = transformer.transform([user_msg, assistant_msg])
+
+ assert result[0] is user_msg
+ assert result[1] is assistant_msg
+
+ def test_does_not_mutate_original_message(self):
+ transformer = _TimestampAnnotationTransformer()
+ msg = _tool_msg_with_metadata()
+ original_content = str(msg.content)
+
+ transformer.transform([msg])
+
+ assert str(msg.content) == original_content
+
+ def test_mixed_messages(self):
+ transformer = _TimestampAnnotationTransformer()
+ user_msg = Message(role=Role.USER, content="question")
+ tool_msg = _tool_msg_with_metadata()
+ synthetic_msg = _tool_msg_with_metadata(
+ tool_call_id=f"{SYNTHETIC_TIMESTAMP_CALL_PREFIX}xyz"
+ )
+
+ result = transformer.transform([user_msg, tool_msg, synthetic_msg])
+
+ assert len(result) == 3
+ assert result[0] is user_msg
+ assert "[Timestamp:" in str(result[1].content)
+ assert "[Timestamp:" not in str(result[2].content)
+
+ def test_skips_non_string_content(self):
+ transformer = _TimestampAnnotationTransformer()
+ msg = _tool_msg_with_metadata()
+ msg.content = [{"type": "text", "text": "data"}] # type: ignore[assignment]
+
+ result = transformer.transform([msg])
+
+ assert result[0] is msg
+ assert result[0].content == [{"type": "text", "text": "data"}]
diff --git a/src/tests/unit_tests/timestamp_tooling/test_timestamp_injection_transformer.py b/src/tests/unit_tests/timestamp_tooling/test_timestamp_injection_transformer.py
new file mode 100644
index 00000000..1b58512d
--- /dev/null
+++ b/src/tests/unit_tests/timestamp_tooling/test_timestamp_injection_transformer.py
@@ -0,0 +1,92 @@
+from types import SimpleNamespace
+from unittest.mock import Mock
+
+from aidial_sdk.chat_completion import Message, Role
+
+from quickapp.common.time_provider import TimeProvider
+from quickapp.config.timestamp import ToolCallTimestampConfig
+from quickapp.timestamp_tooling._timestamp_injection_transformer import (
+ _TimestampInjectionTransformer,
+)
+from quickapp.timestamp_tooling._tool_configs import (
+ CURRENT_TIMESTAMP_TOOL_NAME,
+ SYNTHETIC_TIMESTAMP_CALL_PREFIX,
+)
+
+
+def _make_config_provider(enabled: bool = True) -> Mock:
+ features = SimpleNamespace(timestamp=ToolCallTimestampConfig() if enabled else None)
+ config = SimpleNamespace(features=features)
+ provider = Mock()
+ provider.get.return_value = config
+ return provider
+
+
+def _make_transformer(enabled: bool = True) -> _TimestampInjectionTransformer:
+ return _TimestampInjectionTransformer(
+ time_provider=TimeProvider(),
+ config_provider=_make_config_provider(enabled),
+ )
+
+
+class TestTimestampInjectionTransformer:
+ def test_appends_two_synthetic_messages(self):
+ transformer = _make_transformer()
+ messages = [Message(role=Role.USER, content="hello")]
+
+ result = transformer.transform(messages)
+
+ assert len(result) == 3
+ assert result[0] is messages[0]
+ assert result[1].role == Role.ASSISTANT
+ assert result[2].role == Role.TOOL
+
+ def test_synthetic_call_id_has_correct_prefix(self):
+ transformer = _make_transformer()
+ result = transformer.transform([Message(role=Role.USER, content="hi")])
+
+ assistant_msg = result[1]
+ tool_msg = result[2]
+
+ call_id = assistant_msg.tool_calls[0].id
+ assert call_id.startswith(SYNTHETIC_TIMESTAMP_CALL_PREFIX)
+ assert tool_msg.tool_call_id == call_id
+
+ def test_tool_name_matches_config(self):
+ transformer = _make_transformer()
+ result = transformer.transform([Message(role=Role.USER, content="hi")])
+
+ assistant_msg = result[1]
+ assert assistant_msg.tool_calls[0].function.name == CURRENT_TIMESTAMP_TOOL_NAME
+
+ def test_content_contains_iso_timestamp(self):
+ transformer = _make_transformer()
+ result = transformer.transform([Message(role=Role.USER, content="hi")])
+
+ tool_msg = result[2]
+ content = str(tool_msg.content)
+ assert "UTC" in content
+ assert "source=default" in content
+ assert "T" in content
+
+ def test_does_not_modify_original_messages(self):
+ transformer = _make_transformer()
+ original = [Message(role=Role.USER, content="hi")]
+ result = transformer.transform(original)
+
+ assert len(original) == 1
+ assert len(result) == 3
+
+ def test_empty_messages_returns_empty(self):
+ transformer = _make_transformer()
+ messages: list[Message] = []
+ result = transformer.transform(messages)
+
+ assert result is messages
+
+ def test_noop_when_feature_disabled(self):
+ transformer = _make_transformer(enabled=False)
+ messages = [Message(role=Role.USER, content="hi")]
+ result = transformer.transform(messages)
+
+ assert result is messages
diff --git a/src/tests/unit_tests/timestamp_tooling/test_timestamp_metadata_enricher.py b/src/tests/unit_tests/timestamp_tooling/test_timestamp_metadata_enricher.py
new file mode 100644
index 00000000..8c6522b2
--- /dev/null
+++ b/src/tests/unit_tests/timestamp_tooling/test_timestamp_metadata_enricher.py
@@ -0,0 +1,96 @@
+from quickapp.common.completion_result import CompletionResult
+from quickapp.common.message_metadata import (
+ MESSAGE_METADATA_KEY,
+ MessageMetadata,
+ TimestampMetadata,
+ TimestampSource,
+ get_metadata_from_state,
+ set_metadata_in_state,
+)
+from quickapp.common.time_provider import TimeProvider
+from quickapp.timestamp_tooling._timestamp_metadata_enricher import _TimestampMetadataEnricher
+
+
+class TestTimestampMetadataEnricher:
+ def test_enriches_result_without_state(self):
+ enricher = _TimestampMetadataEnricher(TimeProvider())
+ result = CompletionResult(content="data", content_type="text/plain", state=None)
+
+ enricher.enrich(result)
+
+ assert result.state is not None
+ metadata = get_metadata_from_state(result.state)
+ assert metadata is not None
+ assert metadata.timestamp is not None
+ assert metadata.timestamp.response_timestamp is not None
+ assert metadata.timestamp.timestamp_source == TimestampSource.DEFAULT
+ assert metadata.timestamp.timezone_name == "UTC"
+
+ def test_enriches_result_with_empty_state(self):
+ enricher = _TimestampMetadataEnricher(TimeProvider())
+ result = CompletionResult(content="data", content_type="text/plain", state={})
+
+ enricher.enrich(result)
+
+ metadata = get_metadata_from_state(result.state)
+ assert metadata is not None
+ assert metadata.timestamp is not None
+
+ def test_fill_if_absent_preserves_existing_metadata(self):
+ enricher = _TimestampMetadataEnricher(TimeProvider())
+ from datetime import datetime, timezone
+
+ existing_ts = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
+ existing = MessageMetadata(
+ timestamp=TimestampMetadata(
+ response_timestamp=existing_ts,
+ timestamp_source=TimestampSource.REQUEST,
+ timezone_name="Asia/Tokyo",
+ )
+ )
+ state: dict = {}
+ set_metadata_in_state(state, existing)
+ result = CompletionResult(content="data", content_type="text/plain", state=state)
+
+ enricher.enrich(result)
+
+ metadata = get_metadata_from_state(result.state)
+ assert metadata is not None
+ assert metadata.timestamp.response_timestamp == existing_ts
+ assert metadata.timestamp.timestamp_source == TimestampSource.REQUEST
+ assert metadata.timestamp.timezone_name == "Asia/Tokyo"
+
+ def test_enriches_when_metadata_has_no_timestamp(self):
+ enricher = _TimestampMetadataEnricher(TimeProvider())
+ state: dict = {}
+ set_metadata_in_state(state, MessageMetadata())
+ result = CompletionResult(content="data", content_type="text/plain", state=state)
+
+ enricher.enrich(result)
+
+ metadata = get_metadata_from_state(result.state)
+ assert metadata is not None
+ assert metadata.timestamp is not None
+ assert metadata.timestamp.response_timestamp is not None
+
+ def test_uses_correct_timezone(self):
+ from zoneinfo import ZoneInfo
+
+ enricher = _TimestampMetadataEnricher(
+ TimeProvider(tz=ZoneInfo("US/Eastern"), source=TimestampSource.REQUEST)
+ )
+ result = CompletionResult(content="data", content_type="text/plain")
+
+ enricher.enrich(result)
+
+ metadata = get_metadata_from_state(result.state)
+ assert metadata.timestamp.timezone_name == "US/Eastern"
+ assert metadata.timestamp.timestamp_source == TimestampSource.REQUEST
+
+ def test_state_key_is_correct(self):
+ enricher = _TimestampMetadataEnricher(TimeProvider())
+ result = CompletionResult(content="data", content_type="text/plain")
+
+ enricher.enrich(result)
+
+ assert MESSAGE_METADATA_KEY in result.state