Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 50 additions & 72 deletions packages/junior/src/chat/prompt.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
);
Expand All @@ -184,20 +184,18 @@ function formatAvailableSkillsForPrompt(

const sections: string[] = [];

// Available skills: model may load these when they match the request.
const available = [
"<available-skills>",
...(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 = [
"<available-skills>",
"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("</available-skills>");
sections.push(available.join("\n"));
}
available.push("</available-skills>");
sections.push(available.join("\n"));

// User-callable skills: model must not auto-select these.
if (invokedExplicitOnly.length > 0) {
Expand All @@ -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 "<loaded-skills>\n</loaded-skills>";
return null;
}

const lines = ["<loaded-skills>"];
Expand Down Expand Up @@ -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.";

Expand Down Expand Up @@ -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"));
}

Expand All @@ -535,7 +504,7 @@ function buildContextSection(params: {
configuration?: Record<string, unknown>;
invocation: SkillInvocation | null;
turnState?: "fresh" | "resumed";
}): string {
}): string | null {
const blocks: string[][] = [];

if (JUNIOR_WORLD) {
Expand Down Expand Up @@ -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);
}

Expand All @@ -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,
Expand All @@ -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"));
}

Expand All @@ -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?: {
Expand Down Expand Up @@ -702,7 +680,7 @@ export function buildTurnContextPrompt(params: TurnContextPromptInput): string {
}),
buildRuntimeSection(params.runtime ?? {}),
`</${TURN_CONTEXT_TAG}>`,
];
].filter((section): section is string => Boolean(section));

return sections.join("\n\n");
}
54 changes: 10 additions & 44 deletions packages/junior/src/chat/respond-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Comment thread
cursor[bot] marked this conversation as resolved.
if (!trimmedContext) {
return userInput;
}

const sections: string[] = [];

if (trimmedContext) {
sections.push(
"<thread-background>",
trimmedContext,
"</thread-background>",
"",
);
}

if (conversationId) {
sections.push(
"<session-context>",
`- gen_ai.conversation.id: ${conversationId}`,
"</session-context>",
"",
);
}

if (traceId) {
sections.push(
"<turn-context>",
`- trace_id: ${traceId}`,
"</turn-context>",
"",
);
}

sections.push(
'<current-instruction priority="highest">',
return [
"<thread-background>",
trimmedContext,
"</thread-background>",
"",
"<current-instruction>",
userInput,
"</current-instruction>",
);

return sections.join("\n");
].join("\n");
}

/** Encode a non-image attachment as base64 XML for the prompt. */
Expand Down
11 changes: 2 additions & 9 deletions packages/junior/src/chat/respond.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions packages/junior/tests/unit/misc/respond-helpers-user-turn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
[
"<thread-background>",
"alice: budget is due Friday",
"</thread-background>",
"",
"<current-instruction>",
"what now?",
"</current-instruction>",
].join("\n"),
);
});
});
23 changes: 17 additions & 6 deletions packages/junior/tests/unit/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
Expand All @@ -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",
});
Expand All @@ -55,6 +52,17 @@ describe("prompt builders", () => {
expect(firstTurnContext).not.toContain("<assistant>");
expect(firstTurnContext).not.toContain("<thread-participants>");
expect(firstTurnContext).toContain("<requester>");
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);
});

Expand All @@ -68,6 +76,9 @@ describe("prompt builders", () => {
});

expect(turnContext).not.toContain("<requester>");
expect(turnContext).not.toContain("<context>");
expect(turnContext).not.toContain("<capabilities>");
expect(turnContext).not.toContain("<runtime>");
});

it("puts tool guidance in turn context, not the static system prompt", () => {
Expand Down
Loading
Loading