diff --git a/packages/junior/src/chat/prompt.ts b/packages/junior/src/chat/prompt.ts index b373e4d04..562bd4ee3 100644 --- a/packages/junior/src/chat/prompt.ts +++ b/packages/junior/src/chat/prompt.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import { botConfig, getRuntimeMetadata } from "@/chat/config"; +import { botConfig } from "@/chat/config"; import { TURN_CONTEXT_TAG } from "@/chat/turn-context-tag"; import { listReferenceFiles, @@ -171,7 +171,7 @@ function formatSkillEntry(skill: SkillMetadata): string[] { function formatAvailableSkillsForPrompt( skills: SkillMetadata[], invocation: SkillInvocation | null, -): string { +): string | null { const autoSelectable = skills.filter( (s) => s.disableModelInvocation !== true, ); @@ -184,20 +184,18 @@ function formatAvailableSkillsForPrompt( const sections: string[] = []; - // Available skills: model may load these when they match the request. - const available = [ - "", - ...(autoSelectable.length > 0 - ? [ - "Scan before answering. Load the most specific matching skill; do not answer from memory when a skill fits. If none fits, do not load a skill.", - ] - : []), - ]; - for (const skill of autoSelectable) { - available.push(...formatSkillEntry(skill)); + if (autoSelectable.length > 0) { + // Available skills: model may load these when they match the request. + const available = [ + "", + "Scan before answering. Load the most specific matching skill; do not answer from memory when a skill fits. If none fits, do not load a skill.", + ]; + for (const skill of autoSelectable) { + available.push(...formatSkillEntry(skill)); + } + available.push(""); + sections.push(available.join("\n")); } - available.push(""); - sections.push(available.join("\n")); // User-callable skills: model must not auto-select these. if (invokedExplicitOnly.length > 0) { @@ -212,12 +210,12 @@ function formatAvailableSkillsForPrompt( sections.push(userCallable.join("\n")); } - return sections.join("\n"); + return sections.length > 0 ? sections.join("\n") : null; } -function formatLoadedSkillsForPrompt(skills: Skill[]): string { +function formatLoadedSkillsForPrompt(skills: Skill[]): string | null { if (skills.length === 0) { - return "\n"; + return null; } const lines = [""]; @@ -379,23 +377,6 @@ function formatConfigurationLines( ); } -function formatSlackCapabilityNames( - capabilities: - | { - canAddReactions?: boolean; - canCreateCanvas?: boolean; - canPostToChannel?: boolean; - } - | undefined, -): string { - const names = [ - capabilities?.canCreateCanvas ? "canvas_create" : "", - capabilities?.canPostToChannel ? "channel_post" : "", - capabilities?.canAddReactions ? "reaction_add" : "", - ].filter(Boolean); - return names.length > 0 ? names.join(", ") : "none"; -} - const HEADER = "You are a Slack-based helper assistant. Follow the personality block for voice and tone in every reply. The behavior and output blocks define platform mechanics and override personality only when those mechanics conflict."; @@ -500,32 +481,20 @@ function buildIdentitySection(): string { } function buildRuntimeSection(params: { - channelId?: string; - fastModelId?: string; - modelId?: string; - slackCapabilities?: { - canAddReactions?: boolean; - canCreateCanvas?: boolean; - canPostToChannel?: boolean; - }; - thinkingLevel?: string; -}): string { + conversationId?: string; + traceId?: string; +}): string | null { const lines = [ - `- version: ${escapeXml(getRuntimeMetadata().version ?? "unknown")}`, - params.modelId ? `- model: ${escapeXml(params.modelId)}` : "", - params.fastModelId ? `- fast_model: ${escapeXml(params.fastModelId)}` : "", - params.thinkingLevel - ? `- thinking: ${escapeXml(params.thinkingLevel)}` + params.conversationId + ? `- gen_ai.conversation.id: ${escapeXml(params.conversationId)}` : "", - params.channelId ? "- channel: slack" : "", - params.channelId - ? `- slack_capabilities: ${escapeXml( - formatSlackCapabilityNames(params.slackCapabilities), - )}` - : "", - `- sandbox_workspace: ${escapeXml(SANDBOX_WORKSPACE_ROOT)}`, + params.traceId ? `- trace_id: ${escapeXml(params.traceId)}` : "", ].filter(Boolean); + if (lines.length === 0) { + return null; + } + return renderTagBlock("runtime", lines.join("\n")); } @@ -535,7 +504,7 @@ function buildContextSection(params: { configuration?: Record; invocation: SkillInvocation | null; turnState?: "fresh" | "resumed"; -}): string { +}): string | null { const blocks: string[][] = []; if (JUNIOR_WORLD) { @@ -593,6 +562,10 @@ function buildContextSection(params: { } const body = blocks.map((block) => block.join("\n")).join("\n\n"); + if (!body) { + return null; + } + return renderTagBlock("context", body); } @@ -602,12 +575,20 @@ function buildCapabilitiesSection(params: { activeMcpCatalogs: ActiveMcpCatalogSummary[]; invocation: SkillInvocation | null; toolGuidance?: ToolPromptContext[]; -}): string { +}): string | null { const blocks: string[] = []; - blocks.push( - formatAvailableSkillsForPrompt(params.availableSkills, params.invocation), + const availableSkills = formatAvailableSkillsForPrompt( + params.availableSkills, + params.invocation, ); - blocks.push(formatLoadedSkillsForPrompt(params.activeSkills)); + if (availableSkills) { + blocks.push(availableSkills); + } + + const loadedSkills = formatLoadedSkillsForPrompt(params.activeSkills); + if (loadedSkills) { + blocks.push(loadedSkills); + } const activeCatalogs = formatActiveMcpCatalogsForPrompt( params.activeMcpCatalogs, @@ -626,6 +607,10 @@ function buildCapabilitiesSection(params: { blocks.push(renderTagBlock("providers", providerCatalog)); } + if (blocks.length === 0) { + return null; + } + return renderTagBlock("capabilities", blocks.join("\n\n")); } @@ -635,15 +620,8 @@ type TurnContextPromptInput = { activeMcpCatalogs?: ActiveMcpCatalogSummary[]; toolGuidance?: ToolPromptContext[]; runtime?: { - channelId?: string; - fastModelId?: string; - modelId?: string; - slackCapabilities?: { - canAddReactions?: boolean; - canCreateCanvas?: boolean; - canPostToChannel?: boolean; - }; - thinkingLevel?: string; + conversationId?: string; + traceId?: string; }; invocation: SkillInvocation | null; requester?: { @@ -702,7 +680,7 @@ export function buildTurnContextPrompt(params: TurnContextPromptInput): string { }), buildRuntimeSection(params.runtime ?? {}), ``, - ]; + ].filter((section): section is string => Boolean(section)); return sections.join("\n\n"); } diff --git a/packages/junior/src/chat/respond-helpers.ts b/packages/junior/src/chat/respond-helpers.ts index baeb04326..2afb363da 100644 --- a/packages/junior/src/chat/respond-helpers.ts +++ b/packages/junior/src/chat/respond-helpers.ts @@ -149,62 +149,28 @@ export function summarizeMessageText(text: string): string { } /** - * Wrap the current user turn with self-describing marker blocks: background - * first, current instruction last. Ordering matches long-context attention - * guidance for Sonnet and GPT-5. + * Put prior thread text before the current instruction when no Pi history + * exists. These are top-level sibling blocks in the user message. */ export function buildUserTurnText( userInput: string, conversationContext?: string, - metadata?: { - sessionContext?: { conversationId?: string }; - turnContext?: { traceId?: string }; - }, ): string { const trimmedContext = conversationContext?.trim(); - const conversationId = metadata?.sessionContext?.conversationId; - const traceId = metadata?.turnContext?.traceId; - if (!trimmedContext && !conversationId && !traceId) { + if (!trimmedContext) { return userInput; } - const sections: string[] = []; - - if (trimmedContext) { - sections.push( - "", - trimmedContext, - "", - "", - ); - } - - if (conversationId) { - sections.push( - "", - `- gen_ai.conversation.id: ${conversationId}`, - "", - "", - ); - } - - if (traceId) { - sections.push( - "", - `- trace_id: ${traceId}`, - "", - "", - ); - } - - sections.push( - '', + return [ + "", + trimmedContext, + "", + "", + "", userInput, "", - ); - - return sections.join("\n"); + ].join("\n"); } /** Encode a non-image attachment as base64 XML for the prompt. */ diff --git a/packages/junior/src/chat/respond.ts b/packages/junior/src/chat/respond.ts index ee76f2796..d94152749 100644 --- a/packages/junior/src/chat/respond.ts +++ b/packages/junior/src/chat/respond.ts @@ -556,10 +556,6 @@ export async function generateAssistantReply( const userTurnText = buildUserTurnText( userInput, promptConversationContext, - { - sessionContext: { conversationId: sessionConversationId }, - turnContext: { traceId: getActiveTraceId() }, - }, ); const { routerBlocks, userContentParts } = buildUserTurnInput({ omittedImageAttachmentCount: context.omittedImageAttachmentCount ?? 0, @@ -770,11 +766,8 @@ export async function generateAssistantReply( activeMcpCatalogs, toolGuidance, runtime: { - channelId: toolChannelId, - fastModelId: botConfig.fastModelId, - modelId: botConfig.modelId, - slackCapabilities: channelCapabilities, - thinkingLevel: thinkingSelection.thinkingLevel, + conversationId: spanContext.conversationId, + traceId: getActiveTraceId(), }, invocation: skillInvocation, requester: context.requester, diff --git a/packages/junior/tests/unit/misc/respond-helpers-user-turn.test.ts b/packages/junior/tests/unit/misc/respond-helpers-user-turn.test.ts index b0a2ddc43..47d4b12e7 100644 --- a/packages/junior/tests/unit/misc/respond-helpers-user-turn.test.ts +++ b/packages/junior/tests/unit/misc/respond-helpers-user-turn.test.ts @@ -5,4 +5,18 @@ describe("buildUserTurnText", () => { it("returns raw input when no context or metadata is provided", () => { expect(buildUserTurnText("hello")).toBe("hello"); }); + + it("keeps only causal thread context around the current instruction", () => { + expect(buildUserTurnText("what now?", "alice: budget is due Friday")).toBe( + [ + "", + "alice: budget is due Friday", + "", + "", + "", + "what now?", + "", + ].join("\n"), + ); + }); }); diff --git a/packages/junior/tests/unit/prompt.test.ts b/packages/junior/tests/unit/prompt.test.ts index 3741b3719..d32e36558 100644 --- a/packages/junior/tests/unit/prompt.test.ts +++ b/packages/junior/tests/unit/prompt.test.ts @@ -18,9 +18,8 @@ describe("prompt builders", () => { invocation: null, requester: { userId: "U_ALPHA" }, runtime: { - channelId: "C_ALPHA", - modelId: "model-alpha", - thinkingLevel: "medium", + conversationId: "conversation-alpha", + traceId: "trace-alpha", }, turnState: "fresh", }); @@ -40,9 +39,7 @@ describe("prompt builders", () => { invocation: null, requester: { userId: "U_BETA" }, runtime: { - channelId: "C_BETA", - modelId: "model-beta", - thinkingLevel: "high", + conversationId: "conversation-beta", }, turnState: "resumed", }); @@ -55,6 +52,17 @@ describe("prompt builders", () => { expect(firstTurnContext).not.toContain(""); expect(firstTurnContext).not.toContain(""); expect(firstTurnContext).toContain(""); + expect(firstTurnContext).toContain( + "- gen_ai.conversation.id: conversation-alpha", + ); + expect(firstTurnContext).toContain("- trace_id: trace-alpha"); + expect(firstTurnContext).not.toContain("- model:"); + expect(firstTurnContext).not.toContain("- fast_model:"); + expect(firstTurnContext).not.toContain("- channel:"); + expect(firstTurnContext).not.toContain("- slack_capabilities:"); + expect(firstTurnContext).not.toContain("- thinking:"); + expect(firstTurnContext).not.toContain("- sandbox_workspace:"); + expect(firstSystemPrompt).not.toContain("trace-alpha"); expect(buildSystemPrompt()).toBe(firstSystemPrompt); }); @@ -68,6 +76,9 @@ describe("prompt builders", () => { }); expect(turnContext).not.toContain(""); + expect(turnContext).not.toContain(""); + expect(turnContext).not.toContain(""); + expect(turnContext).not.toContain(""); }); it("puts tool guidance in turn context, not the static system prompt", () => { diff --git a/packages/junior/tests/unit/runtime/respond-mcp-progressive-loading.test.ts b/packages/junior/tests/unit/runtime/respond-mcp-progressive-loading.test.ts index 1ab3bde7c..40d43b0e9 100644 --- a/packages/junior/tests/unit/runtime/respond-mcp-progressive-loading.test.ts +++ b/packages/junior/tests/unit/runtime/respond-mcp-progressive-loading.test.ts @@ -17,6 +17,7 @@ const { omitFinalAssistantAfterTool, pushPreToolAssistantMessage, promptCallCount, + promptMessages, promptSeedMessages, recordToolResultMessage, resumeTurnContextCounts, @@ -42,6 +43,7 @@ const { loadSkillsByNameMock: vi.fn(), omitFinalAssistantAfterTool: { value: false }, promptCallCount: { value: 0 }, + promptMessages: [] as unknown[], promptSeedMessages: [] as unknown[][], pushPreToolAssistantMessage: { value: false }, recordToolResultMessage: { value: false }, @@ -143,6 +145,7 @@ vi.mock("@earendil-works/pi-agent-core", () => { async prompt(message: unknown) { promptCallCount.value += 1; this.aborted = false; + promptMessages.push(message); promptSeedMessages.push([...this.state.messages]); this.state.messages.push(message); @@ -564,6 +567,7 @@ describe("generateAssistantReply progressive MCP loading", () => { loadSkillsByNameMock.mockReset(); omitFinalAssistantAfterTool.value = false; promptCallCount.value = 0; + promptMessages.length = 0; promptSeedMessages.length = 0; pushPreToolAssistantMessage.value = false; recordToolResultMessage.value = false; @@ -759,6 +763,12 @@ describe("generateAssistantReply progressive MCP loading", () => { }); expect(promptSeedMessages[0]).toEqual(priorMessages); + expect(JSON.stringify(promptMessages[0])).not.toContain( + "duplicated prior transcript", + ); + expect(JSON.stringify(promptMessages[0])).not.toContain( + "", + ); }); it("parks for auth when MCP auth is requested during a tool call", async () => {