Skip to content

Commit 5ca9007

Browse files
authored
Merge pull request #195 from Opencode-DCP/refactor/role-based-prompt-injection
Refactor/role based prompt injection
2 parents 39db021 + 74e762c commit 5ca9007

15 files changed

+293
-87
lines changed

index.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,17 @@ const plugin: Plugin = (async (ctx) => {
5050
const discardEnabled = config.tools.discard.enabled
5151
const extractEnabled = config.tools.extract.enabled
5252

53+
// Use user-role prompts for reasoning models (second person),
54+
// assistant-role prompts for non-reasoning models (first person)
55+
const roleDir = state.isReasoningModel ? "user" : "assistant"
56+
5357
let promptName: string
5458
if (discardEnabled && extractEnabled) {
55-
promptName = "system/system-prompt-both"
59+
promptName = `${roleDir}/system/system-prompt-both`
5660
} else if (discardEnabled) {
57-
promptName = "system/system-prompt-discard"
61+
promptName = `${roleDir}/system/system-prompt-discard`
5862
} else if (extractEnabled) {
59-
promptName = "system/system-prompt-extract"
63+
promptName = `${roleDir}/system/system-prompt-extract`
6064
} else {
6165
return
6266
}

lib/messages/prune.ts

Lines changed: 41 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -2,34 +2,45 @@ import type { SessionState, WithParts } from "../state"
22
import type { Logger } from "../logger"
33
import type { PluginConfig } from "../config"
44
import { loadPrompt } from "../prompt"
5-
import { extractParameterKey, buildToolIdList } from "./utils"
5+
import {
6+
extractParameterKey,
7+
buildToolIdList,
8+
createSyntheticUserMessage,
9+
createSyntheticAssistantMessage,
10+
} from "./utils"
611
import { getLastAssistantMessage, getLastUserMessage, isMessageCompacted } from "../shared-utils"
7-
import { AssistantMessage, UserMessage } from "@opencode-ai/sdk"
812

913
const PRUNED_TOOL_INPUT_REPLACEMENT =
1014
"[content removed to save context, this is not what was written to the file, but a placeholder]"
1115
const PRUNED_TOOL_OUTPUT_REPLACEMENT =
1216
"[Output removed to save context - information superseded or no longer needed]"
13-
const getNudgeString = (config: PluginConfig): string => {
17+
18+
const getNudgeString = (config: PluginConfig, isReasoningModel: boolean): string => {
1419
const discardEnabled = config.tools.discard.enabled
1520
const extractEnabled = config.tools.extract.enabled
21+
const roleDir = isReasoningModel ? "user" : "assistant"
1622

1723
if (discardEnabled && extractEnabled) {
18-
return loadPrompt("nudge/nudge-both")
24+
return loadPrompt(`${roleDir}/nudge/nudge-both`)
1925
} else if (discardEnabled) {
20-
return loadPrompt("nudge/nudge-discard")
26+
return loadPrompt(`${roleDir}/nudge/nudge-discard`)
2127
} else if (extractEnabled) {
22-
return loadPrompt("nudge/nudge-extract")
28+
return loadPrompt(`${roleDir}/nudge/nudge-extract`)
2329
}
2430
return ""
2531
}
2632

27-
const wrapPrunableTools = (content: string): string => `<prunable-tools>
33+
const wrapPrunableToolsUser = (content: string): string => `<prunable-tools>
34+
The following tools have been invoked and are available for pruning. This list does not mandate immediate action. Consider your current goals and the resources you need before discarding valuable tool inputs or outputs. Consolidate your prunes for efficiency; it is rarely worth pruning a single tiny tool output. Keep the context free of noise.
35+
${content}
36+
</prunable-tools>`
37+
38+
const wrapPrunableToolsAssistant = (content: string): string => `<prunable-tools>
2839
I have the following tool outputs available for pruning. I should consider my current goals and the resources I need before discarding valuable inputs or outputs. I should consolidate prunes for efficiency; it is rarely worth pruning a single tiny tool output.
2940
${content}
3041
</prunable-tools>`
3142

32-
const getCooldownMessage = (config: PluginConfig): string => {
43+
const getCooldownMessage = (config: PluginConfig, isReasoningModel: boolean): string => {
3344
const discardEnabled = config.tools.discard.enabled
3445
const extractEnabled = config.tools.extract.enabled
3546

@@ -42,16 +53,12 @@ const getCooldownMessage = (config: PluginConfig): string => {
4253
toolName = "extract tool"
4354
}
4455

45-
return `<prunable-tools>
46-
I just performed context management. I will not use the ${toolName} again until after my next tool use, when a fresh list will be available.
47-
</prunable-tools>`
48-
}
56+
const message = isReasoningModel
57+
? `Context management was just performed. Do not use the ${toolName} again. A fresh list will be available after your next tool use.`
58+
: `I just performed context management. I will not use the ${toolName} again until after my next tool use, when a fresh list will be available.`
4959

50-
const SYNTHETIC_MESSAGE_ID = "msg_01234567890123456789012345"
51-
const SYNTHETIC_PART_ID = "prt_01234567890123456789012345"
52-
const SYNTHETIC_USER_MESSAGE_ID = "msg_01234567890123456789012346"
53-
const SYNTHETIC_USER_PART_ID = "prt_01234567890123456789012346"
54-
const REASONING_MODEL_USER_MESSAGE_CONTENT = "[internal: context sync - no response needed]"
60+
return `<prunable-tools>\n${message}\n</prunable-tools>`
61+
}
5562

5663
const buildPrunableToolsList = (
5764
state: SessionState,
@@ -92,7 +99,8 @@ const buildPrunableToolsList = (
9299
return ""
93100
}
94101

95-
return wrapPrunableTools(lines.join("\n"))
102+
const wrapFn = state.isReasoningModel ? wrapPrunableToolsUser : wrapPrunableToolsAssistant
103+
return wrapFn(lines.join("\n"))
96104
}
97105

98106
export const insertPruneToolContext = (
@@ -105,16 +113,14 @@ export const insertPruneToolContext = (
105113
return
106114
}
107115

108-
const lastAssistantMessage = getLastAssistantMessage(messages)
109-
if (!lastAssistantMessage) {
110-
return
111-
}
116+
// For reasoning models, inject into user role; for non-reasoning, inject into assistant role
117+
const isReasoningModel = state.isReasoningModel
112118

113119
let prunableToolsContent: string
114120

115121
if (state.lastToolPrune) {
116122
logger.debug("Last tool was prune - injecting cooldown message")
117-
prunableToolsContent = getCooldownMessage(config)
123+
prunableToolsContent = getCooldownMessage(config, isReasoningModel)
118124
} else {
119125
const prunableToolsList = buildPrunableToolsList(state, config, logger, messages)
120126
if (!prunableToolsList) {
@@ -129,69 +135,24 @@ export const insertPruneToolContext = (
129135
state.nudgeCounter >= config.tools.settings.nudgeFrequency
130136
) {
131137
logger.info("Inserting prune nudge message")
132-
nudgeString = "\n" + getNudgeString(config)
138+
nudgeString = "\n" + getNudgeString(config, isReasoningModel)
133139
}
134140

135141
prunableToolsContent = prunableToolsList + nudgeString
136142
}
137143

138-
const assistantInfo = lastAssistantMessage.info as AssistantMessage
139-
const assistantMessage: WithParts = {
140-
info: {
141-
id: SYNTHETIC_MESSAGE_ID,
142-
sessionID: assistantInfo.sessionID,
143-
role: "assistant",
144-
parentID: assistantInfo.parentID,
145-
modelID: assistantInfo.modelID,
146-
providerID: assistantInfo.providerID,
147-
time: { created: Date.now() },
148-
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
149-
cost: 0,
150-
path: assistantInfo.path,
151-
mode: assistantInfo.mode,
152-
},
153-
parts: [
154-
{
155-
id: SYNTHETIC_PART_ID,
156-
sessionID: assistantInfo.sessionID,
157-
messageID: SYNTHETIC_MESSAGE_ID,
158-
type: "text",
159-
text: prunableToolsContent,
160-
},
161-
],
162-
}
163-
164-
messages.push(assistantMessage)
165-
166-
// For reasoning models, append a synthetic user message to close the assistant turn.
167-
if (state.isReasoningModel) {
168-
const lastRealUserMessage = getLastUserMessage(messages)
169-
const userMessageInfo = lastRealUserMessage?.info as UserMessage | undefined
170-
171-
const userMessage: WithParts = {
172-
info: {
173-
id: SYNTHETIC_USER_MESSAGE_ID,
174-
sessionID: assistantInfo.sessionID,
175-
role: "user",
176-
time: { created: Date.now() + 1 },
177-
agent: userMessageInfo?.agent ?? "code",
178-
model: userMessageInfo?.model ?? {
179-
providerID: assistantInfo.providerID,
180-
modelID: assistantInfo.modelID,
181-
},
182-
} as UserMessage,
183-
parts: [
184-
{
185-
id: SYNTHETIC_USER_PART_ID,
186-
sessionID: assistantInfo.sessionID,
187-
messageID: SYNTHETIC_USER_MESSAGE_ID,
188-
type: "text",
189-
text: REASONING_MODEL_USER_MESSAGE_CONTENT,
190-
},
191-
],
144+
if (isReasoningModel) {
145+
const lastUserMessage = getLastUserMessage(messages)
146+
if (!lastUserMessage) {
147+
return
148+
}
149+
messages.push(createSyntheticUserMessage(lastUserMessage, prunableToolsContent))
150+
} else {
151+
const lastAssistantMessage = getLastAssistantMessage(messages)
152+
if (!lastAssistantMessage) {
153+
return
192154
}
193-
messages.push(userMessage)
194-
logger.debug("Appended synthetic user message for reasoning model")
155+
messages.push(createSyntheticAssistantMessage(lastAssistantMessage, prunableToolsContent))
195156
}
196157
}
197158

@@ -218,7 +179,6 @@ const pruneToolOutputs = (state: SessionState, logger: Logger, messages: WithPar
218179
if (!state.prune.toolIds.includes(part.callID)) {
219180
continue
220181
}
221-
// Skip write and edit tools - their inputs are pruned instead
222182
if (part.tool === "write" || part.tool === "edit") {
223183
continue
224184
}
@@ -238,16 +198,13 @@ const pruneToolInputs = (state: SessionState, logger: Logger, messages: WithPart
238198
if (!state.prune.toolIds.includes(part.callID)) {
239199
continue
240200
}
241-
// Only prune inputs for write and edit tools
242201
if (part.tool !== "write" && part.tool !== "edit") {
243202
continue
244203
}
245-
// Don't prune yet if tool is still pending or running
246204
if (part.state.status === "pending" || part.state.status === "running") {
247205
continue
248206
}
249207

250-
// Write tool has content field, edit tool has oldString/newString fields
251208
if (part.tool === "write" && part.state.input?.content !== undefined) {
252209
part.state.input.content = PRUNED_TOOL_INPUT_REPLACEMENT
253210
}

lib/messages/utils.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,67 @@
11
import { Logger } from "../logger"
22
import { isMessageCompacted } from "../shared-utils"
33
import type { SessionState, WithParts } from "../state"
4+
import type { AssistantMessage, UserMessage } from "@opencode-ai/sdk"
5+
6+
const SYNTHETIC_MESSAGE_ID = "msg_01234567890123456789012345"
7+
const SYNTHETIC_PART_ID = "prt_01234567890123456789012345"
8+
9+
export const createSyntheticUserMessage = (baseMessage: WithParts, content: string): WithParts => {
10+
const userInfo = baseMessage.info as UserMessage
11+
return {
12+
info: {
13+
id: SYNTHETIC_MESSAGE_ID,
14+
sessionID: userInfo.sessionID,
15+
role: "user",
16+
time: { created: Date.now() },
17+
agent: userInfo.agent || "code",
18+
model: {
19+
providerID: userInfo.model.providerID,
20+
modelID: userInfo.model.modelID,
21+
},
22+
},
23+
parts: [
24+
{
25+
id: SYNTHETIC_PART_ID,
26+
sessionID: userInfo.sessionID,
27+
messageID: SYNTHETIC_MESSAGE_ID,
28+
type: "text",
29+
text: content,
30+
},
31+
],
32+
}
33+
}
34+
35+
export const createSyntheticAssistantMessage = (
36+
baseMessage: WithParts,
37+
content: string,
38+
): WithParts => {
39+
const assistantInfo = baseMessage.info as AssistantMessage
40+
return {
41+
info: {
42+
id: SYNTHETIC_MESSAGE_ID,
43+
sessionID: assistantInfo.sessionID,
44+
role: "assistant",
45+
parentID: assistantInfo.parentID,
46+
modelID: assistantInfo.modelID,
47+
providerID: assistantInfo.providerID,
48+
time: { created: Date.now() },
49+
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
50+
cost: 0,
51+
path: assistantInfo.path,
52+
mode: assistantInfo.mode,
53+
},
54+
parts: [
55+
{
56+
id: SYNTHETIC_PART_ID,
57+
sessionID: assistantInfo.sessionID,
58+
messageID: SYNTHETIC_MESSAGE_ID,
59+
type: "text",
60+
text: content,
61+
},
62+
],
63+
}
64+
}
465

566
/**
667
* Extracts a human-readable key from tool metadata for display purposes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<instruction name=context_management_required>
2+
**CRITICAL CONTEXT WARNING:** Your context window is filling with tool outputs. Strict adherence to context hygiene is required.
3+
4+
**Immediate Actions Required:**
5+
1. **Task Completion:** If a sub-task is complete, decide: use `discard` if no valuable context to preserve (default), or use `extract` if insights are worth keeping.
6+
2. **Noise Removal:** If you read files or ran commands that yielded no value, use `discard` to remove them.
7+
3. **Knowledge Preservation:** If you are holding valuable raw data you'll need to reference later, use `extract` to distill the insights and remove the raw entry.
8+
9+
**Protocol:** You should prioritize this cleanup, but do not interrupt a critical atomic operation if one is in progress. Once the immediate step is done, you must perform context management.
10+
</instruction>

0 commit comments

Comments
 (0)