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