1818import java .io .Serializable ;
1919import java .time .Duration ;
2020import java .time .Instant ;
21+ import java .time .ZonedDateTime ;
22+ import java .time .format .DateTimeFormatter ;
23+ import java .time .format .TextStyle ;
24+ import java .util .ArrayList ;
2125import java .util .Collections ;
2226import java .util .HashMap ;
2327import java .util .HashSet ;
2428import java .util .List ;
29+ import java .util .Locale ;
2530import java .util .Map ;
2631import java .util .Objects ;
2732import java .util .Set ;
5358import com .vaadin .flow .component .upload .Upload ;
5459import com .vaadin .flow .component .upload .UploadHelper ;
5560import com .vaadin .flow .component .upload .UploadManager ;
61+ import com .vaadin .flow .function .SerializableSupplier ;
5662import com .vaadin .flow .server .streams .UploadHandler ;
5763
64+ import tools .jackson .databind .JsonNode ;
65+
5866/**
5967 * Orchestrator for AI-powered chat interfaces.
6068 * <p>
@@ -162,6 +170,13 @@ private static void unclaim(Object instance) {
162170 }
163171 }
164172
173+ /**
174+ * Name of the built-in tool that exposes per-turn session context to the
175+ * LLM. Reserved — applications should not register their own tool with this
176+ * name.
177+ */
178+ static final String SESSION_CONTEXT_TOOL_NAME = "get_session_context" ;
179+
165180 private transient LLMProvider provider ;
166181 private final String systemPrompt ;
167182 private AIMessageList messageList ;
@@ -174,6 +189,7 @@ private static void unclaim(Object instance) {
174189 private RequestListener requestListener ;
175190 private AttachmentClickListener attachmentClickListener ;
176191 private ResponseListener responseListener ;
192+ private SerializableSupplier <String > contextSupplier ;
177193 private final Map <AIMessage , String > itemToMessageId = new HashMap <>();
178194 private final List <ChatMessage > conversationHistory = new CopyOnWriteArrayList <>();
179195
@@ -528,6 +544,7 @@ private LLMProvider.LLMRequest buildRequest(String userMessage,
528544 var controllerTools = controller != null
529545 && controller .getTools () != null ? controller .getTools ()
530546 : List .<LLMProvider .ToolSpec > of ();
547+ final var explicitTools = mergeWithContextTool (controllerTools );
531548 return new LLMProvider .LLMRequest () {
532549
533550 @ Override
@@ -552,11 +569,114 @@ public Object[] tools() {
552569
553570 @ Override
554571 public List <LLMProvider .ToolSpec > explicitTools () {
555- return controllerTools ;
572+ return explicitTools ;
556573 }
557574 };
558575 }
559576
577+ /**
578+ * Resolves the configured {@link Supplier} for session context and, if it
579+ * returns non-empty content, prepends a {@value #SESSION_CONTEXT_TOOL_NAME}
580+ * tool that carries that content in its description. The resolved string is
581+ * captured in the per-turn tool instance so {@code execute()} can return it
582+ * without re-invoking the supplier off the UI thread.
583+ * <p>
584+ * A supplier that throws aborts the turn — the exception propagates through
585+ * {@link #buildRequest} and is handled by the existing error path in
586+ * {@link #processUserInput}.
587+ */
588+ private List <LLMProvider .ToolSpec > mergeWithContextTool (
589+ List <LLMProvider .ToolSpec > controllerTools ) {
590+ if (contextSupplier == null ) {
591+ return controllerTools ;
592+ }
593+ var resolved = contextSupplier .get ();
594+ if (resolved == null || resolved .isBlank ()) {
595+ return controllerTools ;
596+ }
597+ var contextTool = buildSessionContextTool (resolved );
598+ var merged = new ArrayList <LLMProvider .ToolSpec >(
599+ controllerTools .size () + 1 );
600+ merged .add (contextTool );
601+ merged .addAll (controllerTools );
602+ return List .copyOf (merged );
603+ }
604+
605+ /**
606+ * Builds the per-turn {@value #SESSION_CONTEXT_TOOL_NAME} tool. The
607+ * resolved content is baked into the description so the LLM sees it just
608+ * from listing the available tools — no separate call is normally needed.
609+ * {@link LLMProvider.ToolSpec#execute} returns the same content so a model
610+ * that does call the tool gets exactly what the description already
611+ * carries.
612+ */
613+ private static LLMProvider .ToolSpec buildSessionContextTool (
614+ String content ) {
615+ return new SessionContextTool (content );
616+ }
617+
618+ private static final DateTimeFormatter DEFAULT_CONTEXT_DATE_TIME_FORMAT = DateTimeFormatter
619+ .ofPattern ("yyyy-MM-dd'T'HH:mmXXX" );
620+
621+ /**
622+ * Default supplier installed when {@link Builder#withMetadata} has not been
623+ * called. Renders the server clock as e.g.
624+ * {@code "Current server date and time: 2026-05-28T17:42+03:00 (Friday, Europe/Helsinki)"}
625+ * so the LLM can interpret relative date references without guessing.
626+ */
627+ private static SerializableSupplier <String > defaultContextSupplier () {
628+ return () -> {
629+ var now = ZonedDateTime .now ();
630+ var dayOfWeek = now .getDayOfWeek ().getDisplayName (TextStyle .FULL ,
631+ Locale .ENGLISH );
632+ return String .format ("Current server date and time: %s (%s, %s)" ,
633+ now .format (DEFAULT_CONTEXT_DATE_TIME_FORMAT ), dayOfWeek ,
634+ now .getZone ().getId ());
635+ };
636+ }
637+
638+ /**
639+ * Per-turn {@link LLMProvider.ToolSpec} that surfaces the resolved session
640+ * context. {@link Serializable} so the orchestrator's per-turn explicit
641+ * tools list does not break the serialization round-trip test even though
642+ * tools themselves are rebuilt on every turn.
643+ */
644+ private static final class SessionContextTool
645+ implements LLMProvider .ToolSpec , Serializable {
646+ private final String content ;
647+ private final String description ;
648+
649+ SessionContextTool (String content ) {
650+ this .content = content ;
651+ this .description = """
652+ Read for current session context (e.g. date and time, user \
653+ locale). The content below is captured at the start of \
654+ this turn:
655+
656+ """ + content ;
657+ }
658+
659+ @ Override
660+ public String getName () {
661+ return SESSION_CONTEXT_TOOL_NAME ;
662+ }
663+
664+ @ Override
665+ public String getDescription () {
666+ return description ;
667+ }
668+
669+ @ Override
670+ public String getParametersSchema () {
671+ return null ;
672+ }
673+
674+ @ Override
675+ public String execute (JsonNode arguments ) {
676+ return content ;
677+ }
678+ }
679+
560680 private void fireResponseListener (String responseText , Throwable error ,
561681 UI ui ) {
562682 if (responseListener != null ) {
@@ -607,6 +727,11 @@ private static void validateToolNames(List<LLMProvider.ToolSpec> tools) {
607727 + "(pattern: "
608728 + VALID_TOOL_NAME_PATTERN .pattern () + ")." );
609729 }
730+ if (SESSION_CONTEXT_TOOL_NAME .equals (name )) {
731+ LOGGER .warn (
732+ "Tool name '{}' is reserved for the built-in session context tool" ,
733+ name );
734+ }
610735 if (!seen .add (name )) {
611736 LOGGER .warn (
612737 "Duplicate tool name '{}': previous tool will be replaced" ,
@@ -781,6 +906,12 @@ public void apply() {
781906 * <li>{@link #withHistory(List, Map)} – restores a previously saved
782907 * conversation history with attachments (from
783908 * {@link AIOrchestrator#getHistory()}).</li>
909+ * <li>{@link #withMetadata(SerializableSupplier)} – sets a supplier the
910+ * orchestrator invokes on every turn to give the LLM free-form session
911+ * context. Defaults to a current-date-and-time supplier so the LLM can
912+ * interpret relative date/time references; pass {@code null} to disable, or
913+ * a custom supplier to include tenant, locale, page state, or anything else
914+ * worth keeping out of the system prompt.</li>
784915 * </ul>
785916 * <p>
786917 * Both Flow components ({@link MessageInput}, {@link MessageList},
@@ -804,6 +935,8 @@ public static class Builder {
804935 private ResponseListener responseListener ;
805936 private List <ChatMessage > history ;
806937 private Map <String , List <AIAttachment >> historyAttachments ;
938+ private SerializableSupplier <String > contextSupplier ;
939+ private boolean contextSupplierSet ;
807940
808941 private Builder (LLMProvider provider , String systemPrompt ) {
809942 Objects .requireNonNull (provider , "Provider cannot be null" );
@@ -1101,6 +1234,49 @@ public Builder withResponseListener(ResponseListener listener) {
11011234 return this ;
11021235 }
11031236
1237+ /**
1238+ * Sets the supplier of free-form session context the LLM sees on every
1239+ * turn. The supplier is invoked once per turn on the UI thread when the
1240+ * request is being built.
1241+ * <p>
1242+ * The supplier may return any string the application wants the LLM to
1243+ * have on hand — current date and time, the active tenant, the user's
1244+ * locale, the page the user is on, feature flags. Compose multiple
1245+ * pieces of context with plain string concatenation.
1246+ * <p>
1247+ * If the supplier returns {@code null} or an empty/blank string, no
1248+ * context is added for that turn — useful for "context only when X"
1249+ * patterns. If the supplier throws, the turn is aborted via the normal
1250+ * error path: the assistant placeholder is updated to a generic error
1251+ * message, {@link AIController#onResponse(Throwable)} fires with the
1252+ * thrown exception, and the exception propagates to the caller of the
1253+ * prompt entry point.
1254+ * <p>
1255+ * Passing {@code null} disables session context entirely, including the
1256+ * built-in default. By default, the orchestrator installs a supplier
1257+ * that yields the current date and time so the LLM can interpret
1258+ * relative date/time references ("show me sales from the past two
1259+ * months") without having to guess.
1260+ * <p>
1261+ * The supplier runs on the UI thread, so it can read
1262+ * {@link UI#getCurrent()} and session-scoped state.
1263+ *
1264+ * @param contextSupplier
1265+ * supplier of the per-turn context string, or {@code null}
1266+ * to disable session context entirely
1267+ * @return this builder
1268+ */
1269+ public Builder withMetadata (
1270+ SerializableSupplier <String > contextSupplier ) {
1271+ if (contextSupplierSet ) {
1272+ LOGGER .warn ("Context supplier was already set on the "
1273+ + "builder and will be replaced" );
1274+ }
1275+ this .contextSupplier = contextSupplier ;
1276+ this .contextSupplierSet = true ;
1277+ return this ;
1278+ }
1279+
11041280 /**
11051281 * Sets the conversation history and associated attachments to restore
11061282 * when the orchestrator is built. This restores the LLM provider's
@@ -1151,6 +1327,8 @@ public AIOrchestrator build() {
11511327 orchestrator .requestListener = requestListener ;
11521328 orchestrator .attachmentClickListener = attachmentClickListener ;
11531329 orchestrator .responseListener = responseListener ;
1330+ orchestrator .contextSupplier = contextSupplierSet ? contextSupplier
1331+ : defaultContextSupplier ();
11541332 try {
11551333 if (input != null ) {
11561334 input .addSubmitListener (
0 commit comments