From 9b08cb6a1c0259be79fcdbe7706f58868cfb6004 Mon Sep 17 00:00:00 2001 From: Bryant Derosier Date: Thu, 3 Sep 2026 11:38:04 -0400 Subject: [PATCH 1/2] feat(server): add the Builder persona prompt --- .../src/j5/agents/agentPersonaPrompts.test.ts | 30 +++++++++++++ .../src/j5/agents/agentPersonaPrompts.ts | 42 +++++++++++++++++++ .../Adapters/ClaudeAdapterV2.test.ts | 16 +++++++ .../Adapters/ClaudeAdapterV2.ts | 17 ++++++-- .../Adapters/CodexAdapterV2.test.ts | 26 ++++++++++++ .../Adapters/CodexAdapterV2.ts | 9 ++-- .../src/orchestration-v2/ProviderAdapter.ts | 1 + .../orchestration-v2/RuntimePolicy.test.ts | 23 ++++++++++ .../src/orchestration-v2/RuntimePolicy.ts | 12 +++++- 9 files changed, 169 insertions(+), 7 deletions(-) create mode 100644 apps/server/src/j5/agents/agentPersonaPrompts.test.ts create mode 100644 apps/server/src/j5/agents/agentPersonaPrompts.ts diff --git a/apps/server/src/j5/agents/agentPersonaPrompts.test.ts b/apps/server/src/j5/agents/agentPersonaPrompts.test.ts new file mode 100644 index 000000000000..a56bb6b6c406 --- /dev/null +++ b/apps/server/src/j5/agents/agentPersonaPrompts.test.ts @@ -0,0 +1,30 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { + BUILDER_AGENT_PERSONA_INSTRUCTIONS_V1, + getBuiltInAgentPersonaInstructions, +} from "./agentPersonaPrompts.ts"; + +describe("agent persona prompts", () => { + it("defines Builder's inputs, review work, output, and publication boundary", () => { + assert.include(BUILDER_AGENT_PERSONA_INSTRUCTIONS_V1, "Plan Handoff"); + assert.include(BUILDER_AGENT_PERSONA_INSTRUCTIONS_V1, "Diagnosis Handoff"); + assert.include(BUILDER_AGENT_PERSONA_INSTRUCTIONS_V1, "Review Inbox"); + assert.include(BUILDER_AGENT_PERSONA_INSTRUCTIONS_V1, "Critic and Sentry"); + assert.include(BUILDER_AGENT_PERSONA_INSTRUCTIONS_V1, "Code Complete Handoff"); + assert.include(BUILDER_AGENT_PERSONA_INSTRUCTIONS_V1, "Never commit or push"); + }); + + it("resolves only the implemented Builder definition version", () => { + assert.equal( + getBuiltInAgentPersonaInstructions({ personaId: "builder", definitionVersion: 1 }), + BUILDER_AGENT_PERSONA_INSTRUCTIONS_V1, + ); + assert.isUndefined( + getBuiltInAgentPersonaInstructions({ personaId: "builder", definitionVersion: 2 }), + ); + assert.isUndefined( + getBuiltInAgentPersonaInstructions({ personaId: "critic", definitionVersion: 1 }), + ); + }); +}); diff --git a/apps/server/src/j5/agents/agentPersonaPrompts.ts b/apps/server/src/j5/agents/agentPersonaPrompts.ts new file mode 100644 index 000000000000..fc81aafc787b --- /dev/null +++ b/apps/server/src/j5/agents/agentPersonaPrompts.ts @@ -0,0 +1,42 @@ +import type { BuiltInAgentPersonaId } from "@t3tools/contracts"; + +export const BUILDER_AGENT_PERSONA_INSTRUCTIONS_V1 = ` +# Builder + +You implement one approved Plan Handoff, Diagnosis Handoff, or Review Inbox in the current workspace. When the input is a Review Inbox, apply the validated findings from Critic and Sentry. Return a Code Complete Handoff when the work is ready for review. + +## Operating contract + +- Treat the governing handoff as the scope contract. Inspect repository instructions and current state before editing. +- Make the smallest complete implementation that satisfies the handoff. Add or update focused tests when behavior changes. +- Validate the changed behavior with the narrowest relevant tests, checks, or builds. Distinguish source failures from environment or harness failures. +- For Critic and Sentry findings, record each finding as applied, refuted with evidence, or unresolved. Do not silently skip a finding or expand beyond it. +- Preserve unrelated user changes. Stop and report a conflict when the requested work cannot be completed safely within the handoff. + +## Authority boundary + +- You may edit workspace files and run build, test, lint, formatting, and inspection commands needed for the implementation. +- Never commit or push. Never open, update, approve, close, or merge a pull request. Do not perform those actions even when the handoff asks for them. +- Do not claim publication or review approval. A Publisher owns publication; Critic and Sentry own independent review. + +## Required output: Code Complete Handoff + +Return a concise Code Complete Handoff containing: + +1. The governing handoff and implemented scope. +2. Changed paths and a behavior summary. +3. Critic and Sentry finding dispositions, when provided. +4. Tests and checks run with their results. +5. Known limitations, residual risks, and unverified areas. +6. The review-ready diff identity when available. +7. An explicit statement that no commit or push was performed. +`; + +export function getBuiltInAgentPersonaInstructions(input: { + readonly personaId: BuiltInAgentPersonaId; + readonly definitionVersion: number; +}): string | undefined { + return input.personaId === "builder" && input.definitionVersion === 1 + ? BUILDER_AGENT_PERSONA_INSTRUCTIONS_V1 + : undefined; +} diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 8a40bfcaf420..230d940ead29 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -653,6 +653,22 @@ describe("ClaudeAdapterV2 native protocol logging", () => { } }); + it("adds persona instructions to Claude's system prompt without requiring MCP", () => { + const options = makeClaudeQueryOptions({ + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + nativeThreadId: "native-builder-instructions", + resume: false, + cwd: "/workspace", + agentPersonaInstructions: "Builder instructions", + }); + + assert.isObject(options.systemPrompt); + const systemPrompt = options.systemPrompt as { readonly append?: string }; + assert.include(systemPrompt.append ?? "", "Builder instructions"); + assert.include(systemPrompt.append ?? "", ""); + assert.notInclude(systemPrompt.append ?? "", T3_CODE_ORCHESTRATION_INSTRUCTIONS); + }); + it.effect("writes Claude Agent SDK protocol frames to the native provider log", () => Effect.gen(function* () { const writes: Array<{ diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index f14dcf60f757..a7fcf2076134 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -728,6 +728,7 @@ export function makeClaudeQueryOptions(input: { readonly onUserDialog?: ClaudeQueryOptions["onUserDialog"]; readonly supportedDialogKinds?: ClaudeQueryOptions["supportedDialogKinds"]; readonly allowDangerouslySkipPermissions?: boolean; + readonly agentPersonaInstructions?: string; }): ClaudeAgentSdkQueryOptions { const compiledSelection = compileClaudeModelSelection(input.modelSelection); const extraArgs = @@ -753,6 +754,13 @@ export function makeClaudeQueryOptions(input: { ...(typeof querySettings === "object" && querySettings !== null ? querySettings : {}), autoCompactWindow: Number(input.settings.autoCompactWindow), } as ClaudeSdkSettings); + const systemPromptAppend = [ + buildRuntimeInstructions({ harness: "Claude Code" }), + input.mcpServers === undefined ? undefined : T3_CODE_ORCHESTRATION_INSTRUCTIONS, + input.agentPersonaInstructions, + ] + .filter((instructions): instructions is string => instructions !== undefined) + .join("\n\n"); const options: ClaudeAgentSdkQueryOptions = { model: compiledSelection.apiModelId, tools: claudeAgentSdkQueryToolsForSdk(selectedTools), @@ -784,9 +792,7 @@ export function makeClaudeQueryOptions(input: { systemPrompt: { type: "preset" as const, preset: "claude_code" as const, - append: - buildRuntimeInstructions({ harness: "Claude Code" }) + - (input.mcpServers === undefined ? "" : T3_CODE_ORCHESTRATION_INSTRUCTIONS), + append: systemPromptAppend, }, ...(Object.keys(extraArgs).length === 0 ? {} : { extraArgs }), }; @@ -5277,6 +5283,11 @@ export function makeClaudeAdapterV2( canUseTool, onUserDialog, supportedDialogKinds: ["resume_return"], + ...(turnInput.runtimePolicy.agentPersonaInstructions === undefined + ? {} + : { + agentPersonaInstructions: turnInput.runtimePolicy.agentPersonaInstructions, + }), }), }) .pipe( diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index d89815c84a1e..b8dbb740c469 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -470,6 +470,32 @@ describe("CodexAdapterV2 runtime policy", () => { }), ); + it.effect("adds persona instructions without requiring the T3 MCP server", () => + Effect.gen(function* () { + const params = yield* buildCodexTurnStartParams({ + nativeThreadId: "native-builder-instructions", + codexInput: [{ type: "text", text: "implement this handoff" }], + runtimePolicy: { + runtimeMode: "auto-accept-edits", + interactionMode: "default", + cwd: "/workspace", + agentPersonaInstructions: "Builder instructions", + }, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.6-sol", + }, + hasT3Mcp: false, + }); + + assert.equal(params.collaborationMode?.mode, "default"); + assert.equal( + params.collaborationMode?.settings.developer_instructions, + "Builder instructions", + ); + }), + ); + it.effect("adds T3 plan-mode developer instructions when the T3 MCP server is attached", () => Effect.gen(function* () { const params = yield* buildCodexTurnStartParams({ diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 65c6558edb03..a7ec9c7f199f 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -665,21 +665,24 @@ export function buildCodexTurnStartParams(input: { const effort = selectedEffort === undefined ? undefined : yield* decodeTurnReasoningEffort(selectedEffort); const serviceTier = getCodexServiceTierOptionValue(input.modelSelection); - const developerInstructions = + const modeInstructions = input.hasT3Mcp !== true ? undefined : input.runtimePolicy.interactionMode === "plan" ? codexPlanModeDeveloperInstructions(input.browserToolsAvailable ?? true) : codexDefaultModeDeveloperInstructions(input.browserToolsAvailable ?? true); + const developerInstructions = [modeInstructions, input.runtimePolicy.agentPersonaInstructions] + .filter((instructions): instructions is string => instructions !== undefined) + .join("\n\n"); const collaborationMode: CodexSchema.ClientRequest__CollaborationMode | undefined = - input.runtimePolicy.interactionMode !== "plan" && developerInstructions === undefined + input.runtimePolicy.interactionMode !== "plan" && developerInstructions.length === 0 ? undefined : { mode: input.runtimePolicy.interactionMode === "plan" ? "plan" : "default", settings: { model: input.modelSelection.model, reasoning_effort: effort ?? "medium", - ...(developerInstructions === undefined + ...(developerInstructions.length === 0 ? {} : { developer_instructions: developerInstructions }), }, diff --git a/apps/server/src/orchestration-v2/ProviderAdapter.ts b/apps/server/src/orchestration-v2/ProviderAdapter.ts index 387be240febc..67b7ff3a1590 100644 --- a/apps/server/src/orchestration-v2/ProviderAdapter.ts +++ b/apps/server/src/orchestration-v2/ProviderAdapter.ts @@ -50,6 +50,7 @@ export const ProviderAdapterV2RuntimePolicy = Schema.Struct({ approvalPolicy: Schema.optional(Schema.Unknown), sandboxPolicy: Schema.optional(Schema.Unknown), reasoningEffort: Schema.optional(Schema.String), + agentPersonaInstructions: Schema.optional(Schema.String), }); export type ProviderAdapterV2RuntimePolicy = typeof ProviderAdapterV2RuntimePolicy.Type; diff --git a/apps/server/src/orchestration-v2/RuntimePolicy.test.ts b/apps/server/src/orchestration-v2/RuntimePolicy.test.ts index 1b26c3ea8f80..8b83cb1a7b33 100644 --- a/apps/server/src/orchestration-v2/RuntimePolicy.test.ts +++ b/apps/server/src/orchestration-v2/RuntimePolicy.test.ts @@ -13,6 +13,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as ProjectionProjects from "../persistence/Services/ProjectionProjects.ts"; +import { BUILDER_AGENT_PERSONA_INSTRUCTIONS_V1 } from "../j5/agents/agentPersonaPrompts.ts"; import { layerFromProjectRepository, RuntimePolicyV2 } from "./RuntimePolicy.ts"; const projectId = ProjectId.make("project:runtime-policy"); @@ -161,4 +162,26 @@ it.layer(TestLayer)("RuntimePolicyV2", (it) => { }); }), ); + + it.effect("adds the versioned Builder instructions to its runtime policy", () => + Effect.gen(function* () { + const policy = yield* RuntimePolicyV2; + const now = yield* DateTime.now; + const thread = { + ...makeThread({ now, worktreePath: "/project-worktree" }), + agentPersonaAssignment: { + personaId: "builder", + definitionVersion: 1, + authorityPolicy: "workspace-write", + resolvedRoute: "primary", + resolvedDriver: ProviderDriverKind.make("codex"), + resolvedModelSelection: modelSelection, + }, + } satisfies OrchestrationV2AppThread; + + const resolved = yield* policy.resolve({ thread, modelSelection }); + + assert.equal(resolved.agentPersonaInstructions, BUILDER_AGENT_PERSONA_INSTRUCTIONS_V1); + }), + ); }); diff --git a/apps/server/src/orchestration-v2/RuntimePolicy.ts b/apps/server/src/orchestration-v2/RuntimePolicy.ts index e2585257fd8b..98fb8de0e7da 100644 --- a/apps/server/src/orchestration-v2/RuntimePolicy.ts +++ b/apps/server/src/orchestration-v2/RuntimePolicy.ts @@ -11,6 +11,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as ProjectionProjects from "../persistence/Services/ProjectionProjects.ts"; +import { getBuiltInAgentPersonaInstructions } from "../j5/agents/agentPersonaPrompts.ts"; import { translateAgentPersonaProviderPolicy } from "../j5/agents/agentPersonaProviderPolicy.ts"; import { ProviderAdapterV2RuntimePolicy, @@ -62,7 +63,8 @@ function runtimePolicyForThread(input: { readonly thread: OrchestrationV2AppThread; readonly cwd: string | null; }): ProviderAdapterV2RuntimePolicyType { - const authorityPolicy = input.thread.agentPersonaAssignment?.authorityPolicy; + const assignment = input.thread.agentPersonaAssignment; + const authorityPolicy = assignment?.authorityPolicy; const providerPolicy = authorityPolicy === undefined ? { runtimeMode: input.thread.runtimeMode } @@ -70,10 +72,18 @@ function runtimePolicyForThread(input: { authorityPolicy, input.thread.agentPersonaAssignment!.resolvedDriver, ); + const agentPersonaInstructions = + assignment === undefined + ? undefined + : getBuiltInAgentPersonaInstructions({ + personaId: assignment.personaId, + definitionVersion: assignment.definitionVersion, + }); return ProviderAdapterV2RuntimePolicy.make({ ...providerPolicy, interactionMode: input.thread.interactionMode, cwd: input.cwd, + ...(agentPersonaInstructions === undefined ? {} : { agentPersonaInstructions }), }); } From 20045500dbe5136647eb472c8d68381f9613f8a0 Mon Sep 17 00:00:00 2001 From: Bryant Derosier Date: Wed, 9 Sep 2026 11:57:22 -0400 Subject: [PATCH 2/2] refactor(agents): compose persona instructions through J5 prompt helpers The Claude system-prompt append and the Codex developer-instructions expression keep their upstream shape and gain one J5 helper call each; the joining logic lives in j5/agents/agentPersonaPrompts.ts. Co-Authored-By: Claude Fable 5.1 --- .../src/j5/agents/agentPersonaPrompts.ts | 13 ++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 21 +++++++------------ .../Adapters/CodexAdapterV2.ts | 14 ++++++------- 3 files changed, 27 insertions(+), 21 deletions(-) diff --git a/apps/server/src/j5/agents/agentPersonaPrompts.ts b/apps/server/src/j5/agents/agentPersonaPrompts.ts index fc81aafc787b..abfad4a66de1 100644 --- a/apps/server/src/j5/agents/agentPersonaPrompts.ts +++ b/apps/server/src/j5/agents/agentPersonaPrompts.ts @@ -40,3 +40,16 @@ export function getBuiltInAgentPersonaInstructions(input: { ? BUILDER_AGENT_PERSONA_INSTRUCTIONS_V1 : undefined; } + +/** + * Adapter-side composition helpers. Upstream adapters call exactly one of these where they + * already build developer or system instructions, so persona prompt plumbing stays here. + */ +export const withAgentPersonaInstructions = ( + base: string | undefined, + persona: string | undefined, +): string | undefined => + persona === undefined ? base : base === undefined ? persona : `${base}\n\n${persona}`; + +export const agentPersonaPromptSuffix = (persona: string | undefined): string => + persona === undefined ? "" : `\n\n${persona}`; diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index a7fcf2076134..257f4eabd39f 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -143,6 +143,7 @@ import { makeSubagentConversationArtifacts, subagentThreadTitle, } from "../SubagentProjection.ts"; +import { agentPersonaPromptSuffix } from "../../j5/agents/agentPersonaPrompts.ts"; export const CLAUDE_PROVIDER = ProviderDriverKind.make("claudeAgent"); export const CLAUDE_AGENT_SDK_QUERY_PROTOCOL = "claude-agent-sdk.query" as const; @@ -728,7 +729,7 @@ export function makeClaudeQueryOptions(input: { readonly onUserDialog?: ClaudeQueryOptions["onUserDialog"]; readonly supportedDialogKinds?: ClaudeQueryOptions["supportedDialogKinds"]; readonly allowDangerouslySkipPermissions?: boolean; - readonly agentPersonaInstructions?: string; + readonly agentPersonaInstructions?: string | undefined; }): ClaudeAgentSdkQueryOptions { const compiledSelection = compileClaudeModelSelection(input.modelSelection); const extraArgs = @@ -754,13 +755,6 @@ export function makeClaudeQueryOptions(input: { ...(typeof querySettings === "object" && querySettings !== null ? querySettings : {}), autoCompactWindow: Number(input.settings.autoCompactWindow), } as ClaudeSdkSettings); - const systemPromptAppend = [ - buildRuntimeInstructions({ harness: "Claude Code" }), - input.mcpServers === undefined ? undefined : T3_CODE_ORCHESTRATION_INSTRUCTIONS, - input.agentPersonaInstructions, - ] - .filter((instructions): instructions is string => instructions !== undefined) - .join("\n\n"); const options: ClaudeAgentSdkQueryOptions = { model: compiledSelection.apiModelId, tools: claudeAgentSdkQueryToolsForSdk(selectedTools), @@ -792,7 +786,10 @@ export function makeClaudeQueryOptions(input: { systemPrompt: { type: "preset" as const, preset: "claude_code" as const, - append: systemPromptAppend, + append: + buildRuntimeInstructions({ harness: "Claude Code" }) + + (input.mcpServers === undefined ? "" : T3_CODE_ORCHESTRATION_INSTRUCTIONS) + + agentPersonaPromptSuffix(input.agentPersonaInstructions), }, ...(Object.keys(extraArgs).length === 0 ? {} : { extraArgs }), }; @@ -5283,11 +5280,7 @@ export function makeClaudeAdapterV2( canUseTool, onUserDialog, supportedDialogKinds: ["resume_return"], - ...(turnInput.runtimePolicy.agentPersonaInstructions === undefined - ? {} - : { - agentPersonaInstructions: turnInput.runtimePolicy.agentPersonaInstructions, - }), + agentPersonaInstructions: turnInput.runtimePolicy.agentPersonaInstructions, }), }) .pipe( diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index a7ec9c7f199f..49b663989524 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -128,6 +128,7 @@ import { makeSubagentConversationArtifacts, subagentThreadTitle, } from "../SubagentProjection.ts"; +import { withAgentPersonaInstructions } from "../../j5/agents/agentPersonaPrompts.ts"; const CODEX_PROVIDER = ProviderDriverKind.make("codex"); export const CODEX_DRIVER_KIND = CODEX_PROVIDER; @@ -665,24 +666,23 @@ export function buildCodexTurnStartParams(input: { const effort = selectedEffort === undefined ? undefined : yield* decodeTurnReasoningEffort(selectedEffort); const serviceTier = getCodexServiceTierOptionValue(input.modelSelection); - const modeInstructions = + const developerInstructions = withAgentPersonaInstructions( input.hasT3Mcp !== true ? undefined : input.runtimePolicy.interactionMode === "plan" ? codexPlanModeDeveloperInstructions(input.browserToolsAvailable ?? true) - : codexDefaultModeDeveloperInstructions(input.browserToolsAvailable ?? true); - const developerInstructions = [modeInstructions, input.runtimePolicy.agentPersonaInstructions] - .filter((instructions): instructions is string => instructions !== undefined) - .join("\n\n"); + : codexDefaultModeDeveloperInstructions(input.browserToolsAvailable ?? true), + input.runtimePolicy.agentPersonaInstructions, + ); const collaborationMode: CodexSchema.ClientRequest__CollaborationMode | undefined = - input.runtimePolicy.interactionMode !== "plan" && developerInstructions.length === 0 + input.runtimePolicy.interactionMode !== "plan" && developerInstructions === undefined ? undefined : { mode: input.runtimePolicy.interactionMode === "plan" ? "plan" : "default", settings: { model: input.modelSelection.model, reasoning_effort: effort ?? "medium", - ...(developerInstructions.length === 0 + ...(developerInstructions === undefined ? {} : { developer_instructions: developerInstructions }), },