From 24f017a799025ab962e451d94c6daa91b589e4e1 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Mon, 13 Jul 2026 17:41:22 -0700 Subject: [PATCH 1/3] fix(eve): merge system instructions Signed-off-by: Rui Conti --- .changeset/merge-system-instructions.md | 5 ++ .../instrumentation-runtime-context.ts | 2 +- packages/eve/src/harness/tool-loop.test.ts | 50 +++++++++---------- packages/eve/src/harness/tool-loop.ts | 39 ++++++++++++++- .../eve/src/public/instrumentation/index.ts | 6 +-- 5 files changed, 70 insertions(+), 32 deletions(-) create mode 100644 .changeset/merge-system-instructions.md diff --git a/.changeset/merge-system-instructions.md b/.changeset/merge-system-instructions.md new file mode 100644 index 000000000..654aed0d7 --- /dev/null +++ b/.changeset/merge-system-instructions.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Model calls now merge multiple system instructions into one message, avoiding provider failures when dynamic instructions are combined with array-form user content. diff --git a/packages/eve/src/harness/instrumentation-runtime-context.ts b/packages/eve/src/harness/instrumentation-runtime-context.ts index d13d99702..2cee9fe84 100644 --- a/packages/eve/src/harness/instrumentation-runtime-context.ts +++ b/packages/eve/src/harness/instrumentation-runtime-context.ts @@ -32,7 +32,7 @@ export interface BuildTelemetryRuntimeContextInput { readonly emissionState: HarnessEmissionState; readonly environment: string; readonly modelInput: { - readonly instructions: string | readonly SystemModelMessage[] | undefined; + readonly instructions: string | SystemModelMessage | undefined; readonly messages: readonly ModelMessage[]; }; readonly session: HarnessSession; diff --git a/packages/eve/src/harness/tool-loop.test.ts b/packages/eve/src/harness/tool-loop.test.ts index de445cc9f..49537fef1 100644 --- a/packages/eve/src/harness/tool-loop.test.ts +++ b/packages/eve/src/harness/tool-loop.test.ts @@ -3534,12 +3534,12 @@ describe("createToolLoopHarness", () => { const reissueTools = reissueCall!.tools as Record; expect(reissueTools.web_search).toBeUndefined(); expect(reissueTools.add).toBeDefined(); - const reissueInstructions = reissueCall!.instructions as Array<{ + const reissueInstructions = reissueCall!.instructions as { role: string; content: string; - }>; - expect(Array.isArray(reissueInstructions)).toBe(true); - expect(reissueInstructions[0]?.content).toContain("web_search"); + }; + expect(reissueInstructions.role).toBe("system"); + expect(reissueInstructions.content).toContain("web_search"); expect(reissueCall!.runtimeContext).toMatchObject({ "eve.retry.reason": "empty-response", }); @@ -3683,7 +3683,7 @@ describe("createToolLoopHarness", () => { it("retries with the offending tool dropped and a one-shot system note", async () => { const resolveRuntimeContext = vi.fn((input: InstrumentationStepStartedEventInput) => ({ runtimeContext: { - "test.attempt": Array.isArray(input.modelInput.instructions) ? "retry" : "original", + "test.attempt": typeof input.modelInput.instructions === "string" ? "original" : "retry", }, })); mockGetInstrumentationConfig.mockReturnValue({ @@ -3769,14 +3769,10 @@ describe("createToolLoopHarness", () => { // The retry's instructions prepend a one-shot system note about // the removed capability so the model has explicit context. - const retryInstructions = retryCall!.instructions as - | string - | Array<{ role: string; content: string }>; - expect(Array.isArray(retryInstructions)).toBe(true); - const noteEntry = (retryInstructions as Array<{ role: string; content: string }>)[0]; - expect(noteEntry?.role).toBe("system"); - expect(noteEntry?.content).toContain("web_search"); - expect(noteEntry?.content).toContain("not available"); + const retryInstructions = retryCall!.instructions as { role: string; content: string }; + expect(retryInstructions.role).toBe("system"); + expect(retryInstructions.content).toContain("web_search"); + expect(retryInstructions.content).toContain("not available"); expect(resolveRuntimeContext.mock.calls[1]?.[0].modelInput.instructions).toEqual( retryInstructions, ); @@ -8760,10 +8756,10 @@ describe("createToolLoopHarness", () => { await runStep(session, { message: "Hi" }); const { instructions, messages } = getLastAgentSettings(); - expect(instructions).toEqual([ - { role: "system", content: "You are a test assistant." }, - { role: "system", content: "durable-system" }, - ]); + expect(instructions).toEqual({ + role: "system", + content: "You are a test assistant.\n\ndurable-system", + }); expect(messages.find((m) => m.role === "system")).toBeUndefined(); expect(messages.at(-1)).toEqual({ role: "user", content: "Hi" }); }); @@ -8807,10 +8803,10 @@ describe("createToolLoopHarness", () => { ); const { instructions } = getLastAgentSettings(); - expect(instructions).toEqual([ - { role: "system", content: "You are a test assistant." }, - { role: "system", content: CONDITIONAL_DELIVERY_INSTRUCTION }, - ]); + expect(instructions).toEqual({ + role: "system", + content: `You are a test assistant.\n\n${CONDITIONAL_DELIVERY_INSTRUCTION}`, + }); }, ); @@ -8883,15 +8879,17 @@ describe("createToolLoopHarness", () => { ctx.set(SessionDynamicInstructionsKey, { context: [{ role: "system" as const, content: "dynamic-system-instruction" }], }); + const userContent: UserContent = [{ type: "text", text: "Hi" }]; - await contextStorage.run(ctx, () => runStep(session, { message: "Hi" })); + await contextStorage.run(ctx, () => runStep(session, { message: userContent })); const { instructions, messages } = getLastAgentSettings(); - expect(instructions).toEqual([ - { role: "system", content: "You are a test assistant." }, - { role: "system", content: "dynamic-system-instruction" }, - ]); + expect(instructions).toEqual({ + role: "system", + content: "You are a test assistant.\n\ndynamic-system-instruction", + }); expect(messages.find((m) => m.content === "dynamic-system-instruction")).toBeUndefined(); + expect(messages.at(-1)?.content).toEqual(userContent); }); it("does not persist dynamic instruction messages to session history", async () => { diff --git a/packages/eve/src/harness/tool-loop.ts b/packages/eve/src/harness/tool-loop.ts index 0b4e30606..248c2f5df 100644 --- a/packages/eve/src/harness/tool-loop.ts +++ b/packages/eve/src/harness/tool-loop.ts @@ -259,6 +259,37 @@ function enrichTelemetry( }; } +function mergeSystemInstructions( + instructions: readonly SystemModelMessage[], +): SystemModelMessage | undefined { + if (instructions.length === 0) { + return undefined; + } + + if (instructions.length === 1) { + return { ...instructions[0]! }; + } + + let providerOptions: SystemModelMessage["providerOptions"] | undefined; + for (const instruction of instructions) { + if (instruction.providerOptions !== undefined) { + providerOptions = { + ...providerOptions, + ...instruction.providerOptions, + }; + } + } + + const merged: SystemModelMessage = { + role: "system", + content: instructions.map((instruction) => instruction.content).join("\n\n"), + }; + if (providerOptions !== undefined) { + merged.providerOptions = providerOptions; + } + return merged; +} + /** * Builds AI Gateway app attribution headers when the model is gateway-routed. * @@ -705,10 +736,14 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn { systemMessages.length > 0 || extraSystemEntry.length > 0 ? [...extraSystemEntry, ...baseSystemEntry, ...systemMessages] : undefined; - const instructions = + const markedInstructions = rawInstructions !== undefined && marker ? applySystemCacheBreakpoint(rawInstructions, marker) - : (rawInstructions ?? session.agent.system ?? undefined); + : rawInstructions; + const instructions = + markedInstructions !== undefined + ? mergeSystemInstructions(markedInstructions) + : (session.agent.system ?? undefined); return { instructions, diff --git a/packages/eve/src/public/instrumentation/index.ts b/packages/eve/src/public/instrumentation/index.ts index 5412e055a..0e73ace9e 100644 --- a/packages/eve/src/public/instrumentation/index.ts +++ b/packages/eve/src/public/instrumentation/index.ts @@ -77,12 +77,12 @@ export interface InstrumentationStep { /** * Final model input assembled for one model-call attempt, snapshotted for - * instrumentation. `instructions` is the resolved system prompt (a single - * string, an array of system messages, or undefined when there is none). + * instrumentation. `instructions` is the resolved system prompt (a string, + * a system message with provider options, or undefined when there is none). * `messages` is the non-system conversation passed to the model. */ export interface InstrumentationModelInput { - readonly instructions: string | readonly SystemModelMessage[] | undefined; + readonly instructions: string | SystemModelMessage | undefined; readonly messages: readonly ModelMessage[]; } From 6ec807fa2ff60fe40c9b7a7e85f62cdf36e26817 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Mon, 13 Jul 2026 19:56:24 -0700 Subject: [PATCH 2/3] test(eve): preserve merged system cache marker Signed-off-by: Rui Conti --- packages/eve/src/harness/tool-loop.test.ts | 28 ++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/eve/src/harness/tool-loop.test.ts b/packages/eve/src/harness/tool-loop.test.ts index 49537fef1..c0573d0ee 100644 --- a/packages/eve/src/harness/tool-loop.test.ts +++ b/packages/eve/src/harness/tool-loop.test.ts @@ -8892,6 +8892,34 @@ describe("createToolLoopHarness", () => { expect(messages.at(-1)?.content).toEqual(userContent); }); + it("preserves the Anthropic system cache breakpoint when merging instructions", async () => { + setupMockAgent(defaultModelResult()); + const runStep = createToolLoopHarness( + createTestConfig("conversation", undefined, { + resolveModel: vi.fn().mockResolvedValue({ + modelId: "claude-sonnet-4-5", + provider: "anthropic.messages", + specificationVersion: "v3", + } as unknown as LanguageModel), + }), + ); + const session = createTestSession(); + const ctx = new ContextContainer(); + ctx.set(SessionDynamicInstructionsKey, { + context: [{ role: "system" as const, content: "dynamic-system-instruction" }], + }); + + await contextStorage.run(ctx, () => runStep(session, { message: "Hi" })); + + expect(getLastAgentSettings().instructions).toEqual({ + role: "system", + content: "You are a test assistant.\n\ndynamic-system-instruction", + providerOptions: { + anthropic: { cacheControl: { type: "ephemeral" } }, + }, + }); + }); + it("does not persist dynamic instruction messages to session history", async () => { setupMockAgent(defaultModelResult()); const runStep = createToolLoopHarness(createTestConfig("conversation")); From 2444f2f599f2f11965bc0d0f8820d9c02c912941 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Mon, 13 Jul 2026 20:00:14 -0700 Subject: [PATCH 3/3] test(eve): use typed prompt cache model stub Signed-off-by: Rui Conti --- packages/eve/src/harness/tool-loop.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/eve/src/harness/tool-loop.test.ts b/packages/eve/src/harness/tool-loop.test.ts index c0573d0ee..2dd512ef8 100644 --- a/packages/eve/src/harness/tool-loop.test.ts +++ b/packages/eve/src/harness/tool-loop.test.ts @@ -7,6 +7,7 @@ import { ToolLoopAgent, type UserContent, } from "ai"; +import { MockLanguageModelV3 } from "ai/test"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ContextContainer, contextStorage } from "#context/container.js"; @@ -8896,11 +8897,12 @@ describe("createToolLoopHarness", () => { setupMockAgent(defaultModelResult()); const runStep = createToolLoopHarness( createTestConfig("conversation", undefined, { - resolveModel: vi.fn().mockResolvedValue({ - modelId: "claude-sonnet-4-5", - provider: "anthropic.messages", - specificationVersion: "v3", - } as unknown as LanguageModel), + resolveModel: vi.fn().mockResolvedValue( + new MockLanguageModelV3({ + modelId: "claude-sonnet-4-5", + provider: "anthropic.messages", + }), + ), }), ); const session = createTestSession();