Skip to content

feat: per-user system prompt via compacted user-settings topic - #47

Merged
tsuz merged 3 commits into
mainfrom
feat/user-settings
Aug 23, 2026
Merged

feat: per-user system prompt via compacted user-settings topic#47
tsuz merged 3 commits into
mainfrom
feat/user-settings

Conversation

@tsuz

@tsuz tsuz commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Summary

Adds a per-user system prompt override, delivered through a new compacted Kafka topic and joined into the session context — mirroring the existing memoir pattern.

  • New topic {AGENT_NAME}-user-settings (compacted, keyed by user_id), value: {"system_prompt": "...", "updated_at": "..."}. A tombstone reverts the user to the default prompt.
  • Gated by ENABLE_USER_SETTING (default false):
    • true → the streams app creates the topic if missing, materializes it as a KTable, and left-joins it into FullSessionContext during enrichment.
    • false → the topology never references the topic and system_prompt stays null.
  • Think consumer: when system_prompt is present and non-blank it replaces the default base prompt (SYSTEM_PROMPT_FILE / built-in) entirely; memoir context is still appended on top.
  • Enrichment refactor: the stream re-keys to user_id once when either memoir or user settings is enabled, so the settings join works with MEMOIR_ENABLED=false and enabling both costs a single repartition.
  • Both FullSessionContext models gained a nullable system_prompt field (@JsonIgnoreProperties(ignoreUnknown=true) on both sides), so mixed old/new deployments are safe. The think-side record keeps a 7-arg compatibility constructor.

Settings update semantics

A KTable join uses the table state at message-flow time, so a settings update takes effect from the user's next turn.

Tests

  • UserSettingTopologyTest (new): flag on / on-without-memoir / off, including topology-description assertions that the store and topic are absent when disabled.
  • EnrichInputMessageProcessorTest: join, absent record, tombstone revert, per-user isolation, memoir+settings combined.
  • SystemPromptTest: override replaces default, null/blank falls back, override composes with memoir.
  • Full suites green: processing 107/107, think-consumer 40/40.

🤖 Generated with Claude Code

Gated by ENABLE_USER_SETTING (default false). When enabled, the streams
app creates the compacted {AGENT_NAME}-user-settings topic (keyed by
user_id), materializes it as a KTable, and left-joins it into
FullSessionContext during enrichment. The think consumer uses the
joined system_prompt as the base prompt in place of the default
(SYSTEM_PROMPT_FILE / built-in); memoir context is still appended on
top. A tombstone reverts the user to the default prompt.

The enrichment processor now re-keys to user_id once when either memoir
or user settings is enabled, so enabling both costs a single
repartition, and the settings join works with MEMOIR_ENABLED=false.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an optional per-user system prompt override sourced from a new compacted Kafka topic, joins it into the session context during stream enrichment, and applies it in the think consumer as a full replacement for the default base prompt (while still appending memoir context).

Changes:

  • Introduces a new compacted *-user-settings topic (Topics.USER_SETTINGS) and a UserSettings model, with topology wiring gated by ENABLE_USER_SETTING.
  • Updates the processing enrichment topology to (optionally) left-join user settings by user_id and carry system_prompt through FullSessionContext.
  • Updates the think consumer to resolve the base prompt from the per-user override (null/blank fallback) and adds/extends tests for the new behavior.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
think/think-consumer/src/test/java/io/flightdeck/think/consumer/SystemPromptTest.java Adds tests for per-user base prompt override resolution and composition with memoir.
think/think-consumer/src/main/java/io/flightdeck/think/model/FullSessionContext.java Adds nullable system_prompt to think-side context plus a compatibility constructor.
think/think-consumer/src/main/java/io/flightdeck/think/consumer/ThinkConsumer.java Applies system_prompt override via resolveBasePrompt() when building the system prompt.
processor-apps/processing/src/test/java/io/flightdeck/streams/UserSettingTopologyTest.java New topology-level tests ensuring user-settings wiring is present/absent based on the flag.
processor-apps/processing/src/test/java/io/flightdeck/streams/processors/EnrichInputMessageProcessorTest.java Adds join semantics tests (present/absent/tombstone/isolation/memoir+settings).
processor-apps/processing/src/main/java/io/flightdeck/streams/processors/EnrichInputMessageProcessor.java Adds optional user-settings join keyed by user_id and enriches FullSessionContext.system_prompt.
processor-apps/processing/src/main/java/io/flightdeck/streams/model/UserSettings.java New model for compacted per-user settings records.
processor-apps/processing/src/main/java/io/flightdeck/streams/model/FullSessionContext.java Adds nullable system_prompt to processing-side context.
processor-apps/processing/src/main/java/io/flightdeck/streams/FlightDeckStreamsApp.java Adds ENABLE_USER_SETTING gating, topic creation, and KTable materialization.
processor-apps/processing/src/main/java/io/flightdeck/streams/config/Topics.java Adds the USER_SETTINGS topic constant and documentation.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +44 to +48
void resolveBasePrompt_nullOrBlank_usesDefault() {
assertThat(ThinkConsumer.resolveBasePrompt(null)).contains("intelligent AI assistant");
assertThat(ThinkConsumer.resolveBasePrompt("")).contains("intelligent AI assistant");
assertThat(ThinkConsumer.resolveBasePrompt(" ")).contains("intelligent AI assistant");
}
Comment on lines 116 to +118
full.history().size(),
full.memoirContext() != null,
full.systemPrompt() != null,
Demonstrates ENABLE_USER_SETTING end to end: a compacted
{AGENT_NAME}-user-settings topic keyed by user_id gives each user their
own system prompt, and a user with no record falls back to the
deployment default.

- seed job writes two personas (SQL tutor, haiku poet); a third user is
  deliberately left unseeded to exercise the left-join null path
- default system prompt answers "I don't know." so the fallback is
  visible in the output
- chat-api/processing/think-consumer build from source, since the
  feature is not in the published images yet
- integration-test.sh asserts each user answered under its own prompt
  and that personas do not cross
- document ENABLE_USER_SETTING in the root README config table

Verified against a local broker: the topic is created compacted, the
enriched context carries the right system_prompt per user (null for the
unseeded one), and message-output shows the three distinct answers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The new user-keyed join path has a confirmed key-collision risk when userId is missing, and the join/logging currently treats blank prompts inconsistently with the think-consumer’s “blank means fallback” semantics.

Review details

Suppressed comments (4)

Previously missed (2) — in code that hasn't changed since the last review.

processor-apps/processing/src/main/java/io/flightdeck/streams/processors/EnrichInputMessageProcessor.java:84

  • The user-keyed join falls back to using sessionId as the join key when userId is null, which can accidentally attach another user's settings/memoir if a sessionId ever equals a real user_id. For user-keyed tables, it's safer to ensure the fallback key can never collide with a user_id (or to skip user-keyed joins when userId is missing).

This issue also appears on line 183 of the same file.

            KStream<String, FullSessionContext> byUser = enriched
                    .selectKey((sessionId, full) ->
                            full.userId() != null ? full.userId() : sessionId);

think/think-consumer/src/main/java/io/flightdeck/think/consumer/ThinkConsumer.java:357

  • Javadoc references FullSessionContext.system_prompt, but the Java accessor is systemPrompt() (the underscore name is only the JSON field). Using the correct Java symbol avoids confusion and broken IDE links.
     * Resolves the base system prompt for a turn: the per-user override from
     * the user-settings topic (carried in {@code FullSessionContext.system_prompt})
     * replaces the default entirely; null/blank falls back to SYSTEM_PROMPT_BASE.
     */

processor-apps/processing/src/main/java/io/flightdeck/streams/processors/EnrichInputMessageProcessor.java:118

  • has_system_prompt is computed as systemPrompt() != null, so a settings record with a blank system_prompt will be logged as "has_system_prompt=true" even though the think-consumer treats blank as fallback. This makes the log signal inaccurate; use a non-blank check instead.
                        log.info("[{}] Enriched — history_size={} has_memoir={} has_system_prompt={} first_turn={}",
                                sessionId,
                                full.history().size(),
                                full.memoirContext() != null,
                                full.systemPrompt() != null,

processor-apps/processing/src/main/java/io/flightdeck/streams/processors/EnrichInputMessageProcessor.java:192

  • The join currently propagates a blank system_prompt string into FullSessionContext. Since the think-consumer treats blank the same as missing (fallback), normalizing blank-to-null here keeps semantics consistent and avoids confusing downstream logs/metrics.
    static FullSessionContext enrichWithUserSettings(FullSessionContext enriched, UserSettings settings) {
        return new FullSessionContext(
                enriched.sessionId(),
                enriched.userId(),
                enriched.cost(),
                enriched.history(),
                enriched.latestInput(),
                enriched.memoirContext(),
                settings != null ? settings.systemPrompt() : null,
                enriched.timestamp()
  • Files reviewed: 17/17 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Two fixes from review of the settings join:

- When user_id is null the join key falls back to session_id, so a
  settings record whose user_id equals that session_id would attach
  another user's system prompt to the turn. A null user_id can happen in
  practice — AggregateToolExecutionResultProcessor leaves it unset when a
  tool result arrives before its seed. Require a non-null user_id before
  applying an override.
- Normalize a blank system_prompt to null, matching the think-consumer,
  which already treats blank as "no override". This also makes the
  has_system_prompt enrichment log accurate.

Also corrects a javadoc reference to the JSON field name rather than the
Java accessor.

Tests: two regression cases (blank normalization, session_id/user_id
collision). Processing 109/109, think-consumer 40/40.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tsuz

tsuz commented Aug 23, 2026

Copy link
Copy Markdown
Owner Author

Addressed the Copilot review (e061d61):

  • Join-key collision (EnrichInputMessageProcessor) — valid, and reachable in practice: AggregateToolExecutionResultProcessor deliberately leaves user_id null when a tool result arrives before its seed, so the sessionId fallback key is live. enrichWithUserSettings now requires a non-null user_id before applying an override, so a settings record keyed by a user_id that happens to equal a session_id can no longer leak into that turn. Regression test added.
  • Blank system_prompt — valid. The join now normalizes blank to null, matching the think-consumer's "blank means fallback" semantics. That also makes has_system_prompt in the enrichment log accurate, so no separate log change was needed.
  • Javadoc referencing system_prompt — fixed to {@link FullSessionContext#systemPrompt()}.

Not changed: the same userId != null ? userId : sessionId fallback in the memoir path (MemoirSessionEndProcessor writes under that key and enrichment reads under it). It's pre-existing and the two sides are paired, so a session-scoped memoir for a user-less session is the current intended behavior — changing it is a separate call, not part of this PR.

Suites green: processing 109/109, think-consumer 40/40.

@tsuz
tsuz merged commit 9518f3a into main Aug 23, 2026
2 checks passed
@tsuz
tsuz deleted the feat/user-settings branch August 23, 2026 08:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants