From c484e633483f7d1b172d14dd0986d67da1cc4abd Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Wed, 4 Mar 2026 22:13:35 +0000 Subject: [PATCH 1/2] refactor: split InstructionMessage into multiple system message blocks Split the single concatenated instruction string into 2-3 separate SystemModelMessages for improved prompt clarity and per-block cache control. Co-Authored-By: Claude Opus 4.6 --- .../split-instruction-message-blocks.md | 5 + .../src/messages/instruction-message.ts | 25 +- packages/runtime/src/messages/message.test.ts | 352 ++++++++++-------- packages/runtime/src/messages/message.ts | 97 +++-- .../states/generating-tool-call.ts | 2 +- 5 files changed, 266 insertions(+), 215 deletions(-) create mode 100644 .changeset/split-instruction-message-blocks.md diff --git a/.changeset/split-instruction-message-blocks.md b/.changeset/split-instruction-message-blocks.md new file mode 100644 index 00000000..0eee6530 --- /dev/null +++ b/.changeset/split-instruction-message-blocks.md @@ -0,0 +1,5 @@ +--- +"perstack": patch +--- + +Split InstructionMessage into multiple system message blocks for improved prompt clarity and per-block cache control diff --git a/packages/runtime/src/messages/instruction-message.ts b/packages/runtime/src/messages/instruction-message.ts index b16c401b..d371675d 100644 --- a/packages/runtime/src/messages/instruction-message.ts +++ b/packages/runtime/src/messages/instruction-message.ts @@ -62,28 +62,25 @@ export function createInstructionMessage(expert: Expert, startedAt: number): Ins ? getCoordinatorMetaInstruction(startedAt) : getDelegateMetaInstruction(startedAt) - const instruction = dedent` + const preamble = dedent` You are Perstack, an AI expert that tackles tasks requested by users by utilizing all available tools. ${metaInstruction} + ` - --- - - ${expert.instruction} + const contents: InstructionMessage["contents"] = [ + { id: createId(), type: "textPart", text: preamble }, + { id: createId(), type: "textPart", text: expert.instruction }, + ] - --- + const skillRules = getSkillRules(expert) + if (skillRules) { + contents.push({ id: createId(), type: "textPart", text: skillRules }) + } - ${getSkillRules(expert)} - ` return { type: "instructionMessage", - contents: [ - { - id: createId(), - type: "textPart", - text: instruction, - }, - ], + contents, id: createId(), } } diff --git a/packages/runtime/src/messages/message.test.ts b/packages/runtime/src/messages/message.test.ts index c3f97230..43b5a35f 100644 --- a/packages/runtime/src/messages/message.test.ts +++ b/packages/runtime/src/messages/message.test.ts @@ -156,7 +156,7 @@ describe("@perstack/messages: message", () => { }) describe("messageToCoreMessage", () => { - it("converts instruction message", () => { + it("converts instruction message to multiple system blocks", () => { const result = messageToCoreMessage({ type: "instructionMessage" as const, id: "msg-1", @@ -169,13 +169,15 @@ describe("@perstack/messages: message", () => { }, ], }) - expect(result).toEqual({ - role: "system", - content: "You are a helpful assistant.", - providerOptions: { - anthropic: { cacheControl: { type: "ephemeral" } }, + expect(result).toEqual([ + { + role: "system", + content: "You are a helpful assistant.", + providerOptions: { + anthropic: { cacheControl: { type: "ephemeral" } }, + }, }, - }) + ]) }) it("converts instruction message without cache", () => { @@ -191,11 +193,45 @@ describe("@perstack/messages: message", () => { }, ], }) - expect(result).toEqual({ - role: "system", - content: "You are a helpful assistant.", - providerOptions: undefined, + expect(result).toEqual([ + { + role: "system", + content: "You are a helpful assistant.", + providerOptions: undefined, + }, + ]) + }) + + it("applies cache control only to the last instruction block", () => { + const result = messageToCoreMessage({ + type: "instructionMessage" as const, + id: "msg-1", + cache: true, + contents: [ + { id: "content-1", type: "textPart" as const, text: "Preamble text" }, + { id: "content-2", type: "textPart" as const, text: "Expert instruction" }, + { id: "content-3", type: "textPart" as const, text: "Skill rules" }, + ], }) + expect(result).toEqual([ + { + role: "system", + content: "Preamble text", + providerOptions: undefined, + }, + { + role: "system", + content: "Expert instruction", + providerOptions: undefined, + }, + { + role: "system", + content: "Skill rules", + providerOptions: { + anthropic: { cacheControl: { type: "ephemeral" } }, + }, + }, + ]) }) describe("user message conversion", () => { @@ -245,48 +281,50 @@ describe("@perstack/messages: message", () => { ], }) - expect(result).toEqual({ - role: "user", - content: [ - { - type: "text", - text: "Hello!", - }, - { - type: "image", - image: "https://example.com/img.png", - mediaType: "image/png", - }, - { - type: "image", - image: "base64", - mediaType: "image/jpeg", - }, - { - type: "image", - image: Buffer.from("binary").toString("base64"), - mediaType: "image/gif", - }, - { - type: "file", - data: "https://example.com/doc.pdf", - mediaType: "application/pdf", - }, - { - type: "file", - data: "filedata", - mediaType: "text/plain", - }, - { - type: "file", - data: Buffer.from("binary").toString("base64"), - mediaType: "application/octet-stream", + expect(result).toEqual([ + { + role: "user", + content: [ + { + type: "text", + text: "Hello!", + }, + { + type: "image", + image: "https://example.com/img.png", + mediaType: "image/png", + }, + { + type: "image", + image: "base64", + mediaType: "image/jpeg", + }, + { + type: "image", + image: Buffer.from("binary").toString("base64"), + mediaType: "image/gif", + }, + { + type: "file", + data: "https://example.com/doc.pdf", + mediaType: "application/pdf", + }, + { + type: "file", + data: "filedata", + mediaType: "text/plain", + }, + { + type: "file", + data: Buffer.from("binary").toString("base64"), + mediaType: "application/octet-stream", + }, + ], + providerOptions: { + anthropic: { cacheControl: { type: "ephemeral" } }, }, - ], - providerOptions: { - anthropic: { cacheControl: { type: "ephemeral" } }, }, - }) + ]) }) it("converts expert message", () => { @@ -305,24 +343,26 @@ describe("@perstack/messages: message", () => { }, ], }) - expect(result).toEqual({ - role: "assistant", - content: [ - { - type: "text", - text: "I can help with that.", - }, - { - type: "tool-call", - toolCallId: "call-1", - toolName: "searchFiles", - input: { query: "test" }, + expect(result).toEqual([ + { + role: "assistant", + content: [ + { + type: "text", + text: "I can help with that.", + }, + { + type: "tool-call", + toolCallId: "call-1", + toolName: "searchFiles", + input: { query: "test" }, + }, + ], + providerOptions: { + anthropic: { cacheControl: { type: "ephemeral" } }, }, - ], - providerOptions: { - anthropic: { cacheControl: { type: "ephemeral" } }, }, - }) + ]) }) it("converts tool message", () => { @@ -341,23 +381,25 @@ describe("@perstack/messages: message", () => { }, ], }) - expect(result).toEqual({ - role: "tool", - content: [ - { - type: "tool-result", - toolCallId: "call-123", - toolName: "readFile", - output: { - type: "text", - value: "File contents", + expect(result).toEqual([ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-123", + toolName: "readFile", + output: { + type: "text", + value: "File contents", + }, }, + ], + providerOptions: { + anthropic: { cacheControl: { type: "ephemeral" } }, }, - ], - providerOptions: { - anthropic: { cacheControl: { type: "ephemeral" } }, }, - }) + ]) }) it("converts tool message with image content", () => { @@ -383,27 +425,29 @@ describe("@perstack/messages: message", () => { }, ], }) - expect(result).toEqual({ - role: "tool", - content: [ - { - type: "tool-result", - toolCallId: "call-456", - toolName: "readImage", - output: { - type: "content", - value: [ - { - type: "media", - data: "base64imagedata", - mediaType: "image/png", - }, - ], + expect(result).toEqual([ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-456", + toolName: "readImage", + output: { + type: "content", + value: [ + { + type: "media", + data: "base64imagedata", + mediaType: "image/png", + }, + ], + }, }, - }, - ], - providerOptions: undefined, - }) + ], + providerOptions: undefined, + }, + ]) }) it("converts user message without cache", () => { @@ -412,11 +456,13 @@ describe("@perstack/messages: message", () => { id: "msg-1", contents: [{ id: "content-1", type: "textPart" as const, text: "Hello!" }], }) - expect(result).toEqual({ - role: "user", - content: [{ type: "text", text: "Hello!" }], - providerOptions: undefined, - }) + expect(result).toEqual([ + { + role: "user", + content: [{ type: "text", text: "Hello!" }], + providerOptions: undefined, + }, + ]) }) it("converts expert message without cache", () => { @@ -425,11 +471,13 @@ describe("@perstack/messages: message", () => { id: "msg-1", contents: [{ id: "content-1", type: "textPart" as const, text: "Response" }], }) - expect(result).toEqual({ - role: "assistant", - content: [{ type: "text", text: "Response" }], - providerOptions: undefined, - }) + expect(result).toEqual([ + { + role: "assistant", + content: [{ type: "text", text: "Response" }], + providerOptions: undefined, + }, + ]) }) it("converts expert message with thinking parts", () => { @@ -447,21 +495,23 @@ describe("@perstack/messages: message", () => { { id: "content-1", type: "textPart" as const, text: "Here is my answer." }, ], }) - expect(result).toEqual({ - role: "assistant", - content: [ - { - type: "reasoning", - text: "Let me think about this...", - providerOptions: { anthropic: { signature: "sig-abc123" } }, - }, - { - type: "text", - text: "Here is my answer.", - }, - ], - providerOptions: undefined, - }) + expect(result).toEqual([ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "Let me think about this...", + providerOptions: { anthropic: { signature: "sig-abc123" } }, + }, + { + type: "text", + text: "Here is my answer.", + }, + ], + providerOptions: undefined, + }, + ]) }) it("converts expert message with thinking part without signature", () => { @@ -478,21 +528,23 @@ describe("@perstack/messages: message", () => { { id: "content-1", type: "textPart" as const, text: "Answer" }, ], }) - expect(result).toEqual({ - role: "assistant", - content: [ - { - type: "reasoning", - text: "Thinking without signature", - providerOptions: undefined, - }, - { - type: "text", - text: "Answer", - }, - ], - providerOptions: undefined, - }) + expect(result).toEqual([ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "Thinking without signature", + providerOptions: undefined, + }, + { + type: "text", + text: "Answer", + }, + ], + providerOptions: undefined, + }, + ]) }) }) }) @@ -502,7 +554,7 @@ describe("@perstack/messages: instruction-message", () => { const startedAt = 1700000000000 describe("createInstructionMessage", () => { - it("creates instruction message with basic expert", () => { + it("creates instruction message with preamble and expert instruction as separate parts", () => { const expert = { key: "test-expert", name: "Test Expert", @@ -517,11 +569,14 @@ describe("@perstack/messages: instruction-message", () => { const result = createInstructionMessage(expert, startedAt) expect(result.type).toBe("instructionMessage") expect(result.cache).toBeUndefined() + expect(result.contents).toHaveLength(2) expect(result.contents[0].type).toBe("textPart") - expect(result.contents[0].text).toContain("You are a test expert.") + expect(result.contents[0].text).toContain("You are Perstack") + expect(result.contents[1].type).toBe("textPart") + expect(result.contents[1].text).toBe("You are a test expert.") }) - it("includes skill rules when present", () => { + it("includes skill rules as a third part when present", () => { const expert = { key: "test-expert", name: "Test Expert", @@ -546,11 +601,12 @@ describe("@perstack/messages: instruction-message", () => { minRuntimeVersion: "v1.0" as const, } const result = createInstructionMessage(expert, startedAt) - expect(result.contents[0].text).toContain("Always use this skill carefully.") - expect(result.contents[0].text).toContain('"test-skill" skill rules:') + expect(result.contents).toHaveLength(3) + expect(result.contents[2].text).toContain("Always use this skill carefully.") + expect(result.contents[2].text).toContain('"test-skill" skill rules:') }) - it("skips skill rules when not present", () => { + it("has only two parts when skill rules are not present", () => { const expert = { key: "test-expert", name: "Test Expert", @@ -573,7 +629,7 @@ describe("@perstack/messages: instruction-message", () => { minRuntimeVersion: "v1.0" as const, } const result = createInstructionMessage(expert, startedAt) - expect(result.contents[0].text).not.toContain('"test-skill" skill rules:') + expect(result.contents).toHaveLength(2) }) it("uses startedAt for timestamp in instruction", () => { diff --git a/packages/runtime/src/messages/message.ts b/packages/runtime/src/messages/message.ts index d4bb1422..0622bfc6 100644 --- a/packages/runtime/src/messages/message.ts +++ b/packages/runtime/src/messages/message.ts @@ -7,7 +7,6 @@ import type { ImageBinaryPart, ImageInlinePart, ImageUrlPart, - InstructionMessage, Message, TextPart, ThinkingPart, @@ -21,14 +20,12 @@ import type { FilePart as FileModelPart, ImagePart as ImageModelPart, ModelMessage, - SystemModelMessage, TextPart as TextModelPart, ToolCallPart as ToolCallModelPart, ToolModelMessage, ToolResultPart as ToolResultModelPart, UserModelMessage, } from "ai" -import { dedent } from "ts-dedent" export function createUserMessage( contents: Array< @@ -87,61 +84,57 @@ export function createToolMessage( } } -export function messageToCoreMessage(message: Message): ModelMessage { +export function messageToCoreMessage(message: Message): ModelMessage[] { switch (message.type) { - case "instructionMessage": - return { - role: "system", - content: instructionContentsToCoreContent(message.contents), - providerOptions: message.cache - ? { - anthropic: { cacheControl: { type: "ephemeral" } }, - } - : undefined, - } + case "instructionMessage": { + const { contents, cache } = message + return contents.map((part, index) => ({ + role: "system" as const, + content: part.text, + providerOptions: + cache && index === contents.length - 1 + ? { anthropic: { cacheControl: { type: "ephemeral" } } } + : undefined, + })) + } case "userMessage": - return { - role: "user", - content: userContentsToCoreContent(message.contents), - providerOptions: message.cache - ? { - anthropic: { cacheControl: { type: "ephemeral" } }, - } - : undefined, - } + return [ + { + role: "user", + content: userContentsToCoreContent(message.contents), + providerOptions: message.cache + ? { + anthropic: { cacheControl: { type: "ephemeral" } }, + } + : undefined, + }, + ] case "expertMessage": - return { - role: "assistant", - content: expertContentsToCoreContent(message.contents), - providerOptions: message.cache - ? { - anthropic: { cacheControl: { type: "ephemeral" } }, - } - : undefined, - } + return [ + { + role: "assistant", + content: expertContentsToCoreContent(message.contents), + providerOptions: message.cache + ? { + anthropic: { cacheControl: { type: "ephemeral" } }, + } + : undefined, + }, + ] case "toolMessage": - return { - role: "tool", - content: toolContentsToCoreContent(message.contents), - providerOptions: message.cache - ? { - anthropic: { cacheControl: { type: "ephemeral" } }, - } - : undefined, - } + return [ + { + role: "tool", + content: toolContentsToCoreContent(message.contents), + providerOptions: message.cache + ? { + anthropic: { cacheControl: { type: "ephemeral" } }, + } + : undefined, + }, + ] } } - -function instructionContentsToCoreContent( - contents: InstructionMessage["contents"], -): SystemModelMessage["content"] { - return contents.reduce((acc, part) => { - return dedent` - ${acc} - ${part.text} - `.trim() - }, "" as string) -} function userContentsToCoreContent(contents: UserMessage["contents"]): UserModelMessage["content"] { return contents.map((part) => { switch (part.type) { diff --git a/packages/runtime/src/state-machine/states/generating-tool-call.ts b/packages/runtime/src/state-machine/states/generating-tool-call.ts index 0bcdf011..42eedaee 100644 --- a/packages/runtime/src/state-machine/states/generating-tool-call.ts +++ b/packages/runtime/src/state-machine/states/generating-tool-call.ts @@ -127,7 +127,7 @@ export async function generatingToolCallLogic({ const executionResult = await llmExecutor.streamText( { - messages: messages.map(messageToCoreMessage), + messages: messages.flatMap(messageToCoreMessage), maxRetries: setting.maxRetries, tools: getToolSet(skillManager), toolChoice: "auto", From 86d74cc588a16ad02004f3bfc8b943bcdf20df48 Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Wed, 4 Mar 2026 22:16:21 +0000 Subject: [PATCH 2/2] fix: update init.test.ts to expect multi-part instruction message contents Co-Authored-By: Claude Opus 4.6 --- packages/runtime/src/state-machine/states/init.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/state-machine/states/init.test.ts b/packages/runtime/src/state-machine/states/init.test.ts index f5d35a13..6a225b65 100644 --- a/packages/runtime/src/state-machine/states/init.test.ts +++ b/packages/runtime/src/state-machine/states/init.test.ts @@ -42,7 +42,10 @@ describe("@perstack/runtime: StateMachineLogic['Init']", () => { { type: "instructionMessage", id: expect.any(String), - contents: [{ type: "textPart", id: expect.any(String), text: expect.any(String) }], + contents: [ + { type: "textPart", id: expect.any(String), text: expect.any(String) }, + { type: "textPart", id: expect.any(String), text: expect.any(String) }, + ], }, { type: "userMessage",