Skip to content

Commit 904d34e

Browse files
tomivirkkiclaude
andauthored
feat: expose per-turn session metadata to the LLM (#9399)
## Summary - The LLM needs ambient session context — current date/time, and optionally tenant, locale, or view state — to interpret relative references like "sales from the past two months" instead of guessing. Putting that in the system prompt would invalidate its cache every turn, so it rides on a built-in get_session_context tool whose description carries the content, keeping the (potentially large) system prompt cacheable. - The orchestrator installs a default supplier that provides the current server date and time; withMetadata lets apps replace or extend it with their own context, or pass null to disable the tool entirely. Closes https://github.com/orgs/vaadin/projects/103/views/3?pane=issue&itemId=193058102 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f10450b commit 904d34e

3 files changed

Lines changed: 391 additions & 10 deletions

File tree

vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/main/java/com/vaadin/flow/component/ai/orchestrator/AIOrchestrator.java

Lines changed: 179 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,15 @@
1818
import java.io.Serializable;
1919
import java.time.Duration;
2020
import 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;
2125
import java.util.Collections;
2226
import java.util.HashMap;
2327
import java.util.HashSet;
2428
import java.util.List;
29+
import java.util.Locale;
2530
import java.util.Map;
2631
import java.util.Objects;
2732
import java.util.Set;
@@ -53,8 +58,11 @@
5358
import com.vaadin.flow.component.upload.Upload;
5459
import com.vaadin.flow.component.upload.UploadHelper;
5560
import com.vaadin.flow.component.upload.UploadManager;
61+
import com.vaadin.flow.function.SerializableSupplier;
5662
import 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(

vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/test/java/com/vaadin/flow/component/ai/AIComponentsSerializableTest.java

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,7 @@ void reconnect_withController_replacesController() throws Throwable {
332332
var tool1 = createToolSpec("originalTool", "Original");
333333
AIController originalController = createController(tool1);
334334

335-
// Build without mocks (no message list) so it can serialize
335+
// Build without mocks (no message list) so it can serialize.
336336
var orchestrator = AIOrchestrator.builder(mockProvider, null)
337337
.withController(originalController).build();
338338

@@ -347,9 +347,14 @@ void reconnect_withController_replacesController() throws Throwable {
347347

348348
var captor = ArgumentCaptor.forClass(LLMProvider.LLMRequest.class);
349349
Mockito.verify(newProvider).stream(captor.capture());
350-
var explicitTools = captor.getValue().explicitTools();
351-
Assertions.assertEquals(1, explicitTools.size());
352-
Assertions.assertEquals("newTool", explicitTools.getFirst().getName());
350+
var toolNames = captor.getValue().explicitTools().stream()
351+
.map(LLMProvider.ToolSpec::getName).toList();
352+
Assertions.assertTrue(toolNames.contains("newTool"),
353+
"Reconnect should install the new controller's tool; got: "
354+
+ toolNames);
355+
Assertions.assertFalse(toolNames.contains("originalTool"),
356+
"Reconnect should drop the previous controller's tool; got: "
357+
+ toolNames);
353358
}
354359

355360
private static AIController createController(

0 commit comments

Comments
 (0)