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
5 changes: 5 additions & 0 deletions .changeset/merge-system-instructions.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
80 changes: 54 additions & 26 deletions packages/eve/src/harness/tool-loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -3534,12 +3535,12 @@ describe("createToolLoopHarness", () => {
const reissueTools = reissueCall!.tools as Record<string, unknown>;
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",
});
Expand Down Expand Up @@ -3683,7 +3684,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({
Expand Down Expand Up @@ -3769,14 +3770,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,
);
Expand Down Expand Up @@ -8760,10 +8757,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" });
});
Expand Down Expand Up @@ -8807,10 +8804,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}`,
});
},
);

Expand Down Expand Up @@ -8883,15 +8880,46 @@ 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("preserves the Anthropic system cache breakpoint when merging instructions", async () => {
setupMockAgent(defaultModelResult());
const runStep = createToolLoopHarness(
createTestConfig("conversation", undefined, {
resolveModel: vi.fn().mockResolvedValue(
new MockLanguageModelV3({
modelId: "claude-sonnet-4-5",
provider: "anthropic.messages",
}),
),
}),
);
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 () => {
Expand Down
39 changes: 37 additions & 2 deletions packages/eve/src/harness/tool-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions packages/eve/src/public/instrumentation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}

Expand Down
Loading