Skip to content

Commit caf93e7

Browse files
committed
fix(mcp): move mini-prompt injection from system prompt to user messages
Mini-prompts appended to the end of the system prompt were being treated as reference material by models rather than actionable instructions. Move injection to two strategies: - Ephemeral: append mini-prompt content to the last user message at API call time (not persisted). Models pay attention to user-message content, giving mini-prompts immediate salience. - One-time persist: when mini-prompts are enabled mid-conversation, add a hidden system-generated user message to history so the model retains context for its decisions even after the prompt is disabled. ChatWidget still handles first-message injection (persisted in userContext for continuity). The existing userContext dedup stripping handles old messages at API time.
1 parent 10875e2 commit caf93e7

4 files changed

Lines changed: 116 additions & 61 deletions

File tree

Info.plist

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@
1919
<key>CFBundlePackageType</key>
2020
<string>APPL</string>
2121
<key>CFBundleShortVersionString</key>
22-
<string>20260404.1</string>
22+
<string>20260408.1</string>
2323
<key>CFBundleVersion</key>
24-
<string>20260404.1</string>
24+
<string>20260408.1</string>
2525
<key>LSApplicationCategoryType</key>
2626
<string>public.app-category.productivity</string>
2727
<key>LSMinimumSystemVersion</key>

Resources/whats-new.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,26 @@
11
{
22
"releases": [
3+
{
4+
"version": "20260408.1",
5+
"release_date": "April 8, 2026",
6+
"introduction": "This release fixes a bug where mini-prompts disappeared mid-conversation, and includes UI improvements for smoother scrolling and conversation navigation.",
7+
"improvements": [
8+
{
9+
"id": "scroll-system-rewrite",
10+
"icon": "arrow.up.arrow.down.circle.fill",
11+
"title": "Rewritten Scroll System",
12+
"description": "The conversation scroll system has been completely rewritten to fix content jumping off-screen. Conversations now scroll to the newest message and auto-focus the input field when opened."
13+
}
14+
],
15+
"bugfixes": [
16+
{
17+
"id": "mini-prompt-mid-conversation",
18+
"icon": "brain.head.profile.fill",
19+
"title": "Fixed Mini-Prompts Disappearing Mid-Conversation",
20+
"description": "Mini-prompts are now injected directly into user messages at API call time, so agents act on them immediately instead of treating them as background context. When you enable a mini-prompt mid-conversation, it's persisted once as a hidden message for continuity and injected ephemerally on every turn for attention."
21+
}
22+
]
23+
},
324
{
425
"version": "20260404.1",
526
"release_date": "April 4, 2026",

Sources/APIFramework/AgentOrchestrator+LLMCalls.swift

Lines changed: 91 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -182,34 +182,11 @@ extension AgentOrchestrator {
182182
}
183183
}
184184

185-
/// MINI PROMPT INJECTION: Include enabled mini-prompts in system prompt
186-
/// Mini-prompts are user-configured persistent instructions for the conversation.
187-
/// By embedding them in the system prompt (which is sent on every call), they are
188-
/// always visible to the agent without consuming additional context slots.
189-
if !conversation.enabledMiniPromptIds.isEmpty {
190-
let miniPromptText = MiniPromptManager.shared.getInjectedText(
191-
for: conversation.id,
192-
enabledIds: conversation.enabledMiniPromptIds
193-
)
194-
if !miniPromptText.isEmpty {
195-
let enabledPrompts = MiniPromptManager.shared.enabledPrompts(
196-
for: conversation.id,
197-
enabledIds: conversation.enabledMiniPromptIds
198-
)
199-
systemPromptAdditions += """
200-
201-
202-
# User Instructions (Mini-Prompts)
203-
204-
The user has specified the following instructions that you MUST follow:
205-
206-
\(miniPromptText)
207-
208-
These instructions take priority. Follow them exactly.
209-
"""
210-
logger.info("callLLM: Injected \(enabledPrompts.count) mini-prompt(s) into system prompt (\(miniPromptText.count) chars)")
211-
}
212-
}
185+
/// MINI PROMPT INJECTION: Handled via ephemeral user-message injection below.
186+
/// Mini-prompts appended to the system prompt get buried under layers of context
187+
/// and models treat them as reference material rather than actionable instructions.
188+
/// Instead, we inject them into the latest user message at API-call time (not persisted).
189+
/// ChatWidget handles first-message injection into <userContext> (persisted for continuity).
213190

214191
/// Session naming: inject instruction for unnamed conversations so AI provides a title
215192
if conversation.title.hasPrefix("New Conversation") {
@@ -488,6 +465,52 @@ extension AgentOrchestrator {
488465
logger.debug("callLLM: Request has \(messages.count) messages (\(messagesToSend.count) conversation + \(internalMessages.count) internal)")
489466
logger.debug("callLLM: User sees \(conversation.messages.count) messages, LLM context uses \(messagesToSend.count) messages")
490467

468+
/// EPHEMERAL MINI-PROMPT INJECTION: Append mini-prompt content to the last user message
469+
/// in the API payload. This is NOT persisted to conversation history - it only exists
470+
/// in the messages array sent to the API. Models pay more attention to content in user
471+
/// messages than system prompts, so this gets mini-prompts acted on rather than ignored.
472+
///
473+
/// ONE-TIME PERSIST: When mini-prompts are enabled mid-conversation, we also add a
474+
/// hidden system-generated user message to conversation history so the model retains
475+
/// context about WHY it made certain decisions even after the prompt is disabled.
476+
if !conversation.enabledMiniPromptIds.isEmpty {
477+
let miniPromptText = MiniPromptManager.shared.getInjectedText(
478+
for: conversation.id,
479+
enabledIds: conversation.enabledMiniPromptIds
480+
)
481+
if !miniPromptText.isEmpty {
482+
/// Check if mini-prompt content is already persisted in conversation history
483+
let alreadyPersisted = conversation.messages.contains { msg in
484+
msg.isFromUser && msg.content.contains(miniPromptText.prefix(100))
485+
}
486+
487+
/// One-time persist: Add hidden message if not already in history.
488+
/// This ensures the model retains context for its decisions even after
489+
/// the mini-prompt is disabled. Hidden from UI via isSystemGenerated.
490+
if !alreadyPersisted {
491+
let persistContent = "<userContext>\n\(miniPromptText)\n</userContext>"
492+
conversation.messageBus?.addUserMessage(
493+
content: persistContent,
494+
isPinned: true,
495+
isSystemGenerated: true
496+
)
497+
logger.info("callLLM: Persisted mini-prompt content as hidden message for conversation continuity (\(miniPromptText.count) chars)")
498+
}
499+
500+
/// Ephemeral injection into last user message for immediate salience
501+
if let lastUserIndex = messages.lastIndex(where: { $0.role == "user" }) {
502+
let existingContent = messages[lastUserIndex].content ?? ""
503+
if !existingContent.contains(miniPromptText.prefix(100)) {
504+
let injectedContent = existingContent + "\n\n<userContext>\n\(miniPromptText)\n</userContext>"
505+
messages[lastUserIndex] = OpenAIChatMessage(role: "user", content: injectedContent)
506+
logger.info("callLLM: Ephemeral mini-prompt injection into last user message (\(miniPromptText.count) chars)")
507+
} else {
508+
logger.debug("callLLM: Mini-prompt already present in last user message")
509+
}
510+
}
511+
}
512+
}
513+
491514
/// Get model's actual context limit for MessageValidator budget calculation
492515
let modelContextLimit = await tokenCounter.getContextSize(modelName: model)
493516
logger.debug("CONTEXT: Model '\(model)' has context limit of \(modelContextLimit) tokens")
@@ -1239,34 +1262,8 @@ extension AgentOrchestrator {
12391262
}
12401263
}
12411264

1242-
/// MINI PROMPT INJECTION: Include enabled mini-prompts in system prompt
1243-
/// Mini-prompts are user-configured persistent instructions for the conversation.
1244-
/// By embedding them in the system prompt (which is sent on every call), they are
1245-
/// always visible to the agent without consuming additional context slots.
1246-
if !conversation.enabledMiniPromptIds.isEmpty {
1247-
let miniPromptText = MiniPromptManager.shared.getInjectedText(
1248-
for: conversation.id,
1249-
enabledIds: conversation.enabledMiniPromptIds
1250-
)
1251-
if !miniPromptText.isEmpty {
1252-
let enabledPrompts = MiniPromptManager.shared.enabledPrompts(
1253-
for: conversation.id,
1254-
enabledIds: conversation.enabledMiniPromptIds
1255-
)
1256-
systemPromptAdditions += """
1257-
1258-
1259-
# User Instructions (Mini-Prompts)
1260-
1261-
The user has specified the following instructions that you MUST follow:
1262-
1263-
\(miniPromptText)
1264-
1265-
These instructions take priority. Follow them exactly.
1266-
"""
1267-
logger.info("callLLMStreaming: Injected \(enabledPrompts.count) mini-prompt(s) into system prompt (\(miniPromptText.count) chars)")
1268-
}
1269-
}
1265+
/// MINI PROMPT INJECTION: Handled via ephemeral user-message injection below.
1266+
/// See callLLM for rationale - system prompt injection gets buried and ignored.
12701267

12711268
/// Session naming: inject instruction for unnamed conversations so AI provides a title
12721269
if conversation.title.hasPrefix("New Conversation") {
@@ -1599,6 +1596,44 @@ extension AgentOrchestrator {
15991596
messages = MessageValidator.validateToolMessagePairs(messages)
16001597
logger.debug("callLLMStreaming: Validated tool message pairs - \(messages.count) messages after validation")
16011598

1599+
/// EPHEMERAL MINI-PROMPT INJECTION + ONE-TIME PERSIST
1600+
/// See callLLM for full rationale - models act on user-message content, ignore system prompt tail.
1601+
if !conversation.enabledMiniPromptIds.isEmpty {
1602+
let miniPromptText = MiniPromptManager.shared.getInjectedText(
1603+
for: conversation.id,
1604+
enabledIds: conversation.enabledMiniPromptIds
1605+
)
1606+
if !miniPromptText.isEmpty {
1607+
/// Check if mini-prompt content is already persisted in conversation history
1608+
let alreadyPersisted = conversation.messages.contains { msg in
1609+
msg.isFromUser && msg.content.contains(miniPromptText.prefix(100))
1610+
}
1611+
1612+
/// One-time persist: Add hidden message if not already in history
1613+
if !alreadyPersisted {
1614+
let persistContent = "<userContext>\n\(miniPromptText)\n</userContext>"
1615+
conversation.messageBus?.addUserMessage(
1616+
content: persistContent,
1617+
isPinned: true,
1618+
isSystemGenerated: true
1619+
)
1620+
logger.info("callLLMStreaming: Persisted mini-prompt content as hidden message (\(miniPromptText.count) chars)")
1621+
}
1622+
1623+
/// Ephemeral injection into last user message for immediate salience
1624+
if let lastUserIndex = messages.lastIndex(where: { $0.role == "user" }) {
1625+
let existingContent = messages[lastUserIndex].content ?? ""
1626+
if !existingContent.contains(miniPromptText.prefix(100)) {
1627+
let injectedContent = existingContent + "\n\n<userContext>\n\(miniPromptText)\n</userContext>"
1628+
messages[lastUserIndex] = OpenAIChatMessage(role: "user", content: injectedContent)
1629+
logger.info("callLLMStreaming: Ephemeral mini-prompt injection into last user message (\(miniPromptText.count) chars)")
1630+
} else {
1631+
logger.debug("callLLMStreaming: Mini-prompt already present in last user message")
1632+
}
1633+
}
1634+
}
1635+
}
1636+
16021637
/// Get model context limit for MessageValidator budget calculation
16031638
let modelContextLimit = await tokenCounter.getContextSize(modelName: model)
16041639
logger.debug("CONTEXT: Model has context limit of \(modelContextLimit) tokens")

Sources/UserInterface/Chat/ChatWidget.swift

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3052,9 +3052,8 @@ public struct ChatWidget: View {
30523052
for: activeConversation.id,
30533053
enabledIds: activeConversation.enabledMiniPromptIds
30543054
)
3055-
/// Mini prompt injection tracking is handled by AgentOrchestrator
3056-
/// ChatWidget should NOT call recordInjection() here - that blocks the
3057-
/// orchestrator from injecting its own <miniPromptReminder> system message
3055+
/// ChatWidget handles first-message persist only. AgentOrchestrator handles
3056+
/// one-time mid-conversation persist and ephemeral per-turn injection.
30583057
logger.info("MINI_PROMPT_TRACE: ChatWidget injected \(enabledPrompts.count) mini-prompt(s) into FIRST user message: \(enabledPrompts.map { $0.name }.joined(separator: ", "))")
30593058
} else {
30603059
logger.debug("MINI_PROMPT_TRACE: ChatWidget miniPromptText was empty")

0 commit comments

Comments
 (0)