Skip to content

Commit e541f54

Browse files
authored
Add thread_with_system template vars (#200)
## Summary - make `thread_with_system` available anywhere prompt templates already support `thread` - let scorer templates use `{{thread_with_system}}` when they need the full conversation, including system messages
1 parent b0500ed commit e541f54

7 files changed

Lines changed: 222 additions & 5 deletions

File tree

js/llm.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
buildClassificationTools,
99
LLMClassifierFromTemplate,
1010
OpenAIClassifier,
11+
templateUsesThreadVariables,
1112
} from "../js/llm";
1213
import {
1314
openaiClassifierShouldEvaluateArithmeticExpressions,
@@ -64,6 +65,15 @@ afterAll(() => {
6465
});
6566

6667
describe("LLM Tests", () => {
68+
test("templateUsesThreadVariables recognizes thread_with_system", () => {
69+
expect(templateUsesThreadVariables("{{thread_with_system}}")).toBe(true);
70+
expect(
71+
templateUsesThreadVariables(
72+
"Full thread: {{thread_with_system.0.content}}",
73+
),
74+
).toBe(true);
75+
});
76+
6777
test("openai classifier should evaluate titles", async () => {
6878
let callCount = -1;
6979
server.use(
@@ -342,6 +352,80 @@ Issue Description: {{page_content}}
342352
expect(capturedRequestBody.reasoning_effort).toBeUndefined();
343353
});
344354

355+
test("LLMClassifierFromTemplate keeps thread filtered while exposing thread_with_system", async () => {
356+
let capturedRequestBody: unknown;
357+
const systemMarker = "TRACE_SYSTEM_MESSAGE";
358+
359+
server.use(
360+
http.post("https://api.openai.com/v1/responses", async ({ request }) => {
361+
capturedRequestBody = await request.json();
362+
363+
return HttpResponse.json({
364+
id: "resp-test",
365+
object: "response",
366+
created: 1234567890,
367+
model: "gpt-5-mini",
368+
output: [
369+
{
370+
type: "function_call",
371+
call_id: "call_test",
372+
name: "select_choice",
373+
arguments: JSON.stringify({ choice: "1" }),
374+
},
375+
],
376+
});
377+
}),
378+
);
379+
380+
const classifier = LLMClassifierFromTemplate({
381+
name: "thread-template",
382+
promptTemplate:
383+
"Filtered thread:\n{{thread}}\n\nFull thread:\n{{thread_with_system}}",
384+
choiceScores: { "1": 1, "2": 0 },
385+
useCoT: false,
386+
});
387+
388+
await classifier({
389+
output: "",
390+
expected: "",
391+
trace: {
392+
async getThread() {
393+
return [
394+
{ role: "system", content: systemMarker },
395+
{ role: "user", content: "Hello" },
396+
{ role: "assistant", content: "Hi there" },
397+
];
398+
},
399+
},
400+
});
401+
402+
if (
403+
!capturedRequestBody ||
404+
typeof capturedRequestBody !== "object" ||
405+
!("input" in capturedRequestBody) ||
406+
!Array.isArray(capturedRequestBody.input)
407+
) {
408+
throw new Error("Unexpected request body shape");
409+
}
410+
411+
const firstInput = capturedRequestBody.input[0];
412+
if (
413+
!firstInput ||
414+
typeof firstInput !== "object" ||
415+
!("content" in firstInput) ||
416+
typeof firstInput.content !== "string"
417+
) {
418+
throw new Error("Unexpected request input shape");
419+
}
420+
421+
const [filteredThread, fullThread] =
422+
firstInput.content.split("\n\nFull thread:\n");
423+
expect(filteredThread).toContain("Hello");
424+
expect(filteredThread).toContain("Hi there");
425+
expect(filteredThread).not.toContain(systemMarker);
426+
expect(fullThread).toContain(systemMarker);
427+
});
428+
345429
test("useResponsesApi forces the Responses API for a non-gpt-5 model", async () => {
346430
let responsesHit = false;
347431
let chatCompletionsHit = false;

js/llm.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export interface TraceForScorer {
3030
// Thread-related template variable names that require preprocessor invocation
3131
export const THREAD_VARIABLE_NAMES = [
3232
"thread",
33+
"thread_with_system",
3334
"thread_count",
3435
"first_message",
3536
"last_message",
@@ -335,7 +336,7 @@ export function LLMClassifierFromTemplate<RenderArgs>({
335336
if (runtimeArgs.trace && templateUsesThreadVariables(promptTemplate)) {
336337
const thread = await runtimeArgs.trace.getThread();
337338
const scorerThread = filterSystemMessagesFromThread(thread);
338-
const computed = computeThreadTemplateVars(scorerThread);
339+
const computed = computeThreadTemplateVars(scorerThread, thread);
339340
// Build threadVars from THREAD_VARIABLE_NAMES to keep in sync with the pattern
340341
for (const name of THREAD_VARIABLE_NAMES) {
341342
threadVars[name] = computed[name as keyof ThreadTemplateVars];

js/render-messages.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it } from "vitest";
22
import { renderMessages } from "./render-messages";
33
import { ChatCompletionMessageParam } from "openai/resources";
4+
import { computeThreadTemplateVars } from "./thread-utils";
45

56
describe("renderMessages", () => {
67
it("should never HTML-escape values, regardless of mustache syntax", () => {
@@ -182,4 +183,42 @@ describe("renderMessages with thread variables", () => {
182183
expect(rendered[0].content).toContain("Assistant:");
183184
expect(rendered[0].content).toContain("Simple response");
184185
});
186+
187+
it("computeThreadTemplateVars can expose thread_with_system separately", () => {
188+
const fullThread = [
189+
{ role: "system", content: "You are a helpful assistant." },
190+
...sampleThread,
191+
];
192+
193+
const renderedVars = computeThreadTemplateVars(sampleThread, fullThread);
194+
195+
expect(renderedVars.thread).toEqual(sampleThread);
196+
expect(renderedVars.thread_with_system).toEqual(fullThread);
197+
expect(renderedVars.thread_count).toBe(sampleThread.length);
198+
expect(renderedVars.first_message).toEqual(sampleThread[0]);
199+
});
200+
201+
it("{{thread_with_system}} renders full conversation and supports indexing", () => {
202+
const fullThread = [
203+
{ role: "system", content: "You are a helpful assistant." },
204+
...sampleThread,
205+
];
206+
const messages: ChatCompletionMessageParam[] = [
207+
{
208+
role: "user",
209+
content:
210+
"Full thread: {{thread_with_system}}\n\nFirst full: {{thread_with_system.0}}",
211+
},
212+
];
213+
const rendered = renderMessages(
214+
messages,
215+
computeThreadTemplateVars(sampleThread, fullThread),
216+
);
217+
218+
expect(rendered[0].content).toContain("System:");
219+
expect(rendered[0].content).toContain("You are a helpful assistant.");
220+
expect(rendered[0].content).toContain(
221+
"First full: system: You are a helpful assistant.",
222+
);
223+
});
185224
});

js/thread-utils.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ export function formatMessageArrayAsText(messages: LLMMessage[]): string {
252252
*/
253253
export interface ThreadTemplateVars {
254254
thread: unknown[];
255+
thread_with_system: unknown[];
255256
thread_count: number;
256257
first_message: unknown | null;
257258
last_message: unknown | null;
@@ -270,6 +271,7 @@ export interface ThreadTemplateVars {
270271
*/
271272
export function computeThreadTemplateVars(
272273
thread: unknown[],
274+
threadWithSystem: unknown[] = thread,
273275
): ThreadTemplateVars {
274276
let _user_messages: unknown[] | undefined;
275277
let _assistant_messages: unknown[] | undefined;
@@ -279,6 +281,7 @@ export function computeThreadTemplateVars(
279281

280282
return {
281283
thread,
284+
thread_with_system: threadWithSystem,
282285
thread_count: thread.length,
283286

284287
get first_message(): unknown | null {

py/autoevals/llm.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -434,7 +434,7 @@ def _compute_thread_vars_sync(self, trace) -> dict[str, object]:
434434
if not isinstance(thread, list):
435435
thread = list(thread)
436436

437-
computed = compute_thread_template_vars(filter_system_messages_from_thread(thread))
437+
computed = compute_thread_template_vars(filter_system_messages_from_thread(thread), thread)
438438
return {name: computed[name] for name in self._thread_variable_names}
439439

440440
async def _compute_thread_vars_async(self, trace) -> dict[str, object]:
@@ -450,7 +450,7 @@ async def _compute_thread_vars_async(self, trace) -> dict[str, object]:
450450
if not isinstance(thread, list):
451451
thread = list(thread)
452452

453-
computed = compute_thread_template_vars(filter_system_messages_from_thread(thread))
453+
computed = compute_thread_template_vars(filter_system_messages_from_thread(thread), thread)
454454
return {name: computed[name] for name in self._thread_variable_names}
455455

456456
def _request_args(self, output, expected, **kwargs):

py/autoevals/test_llm.py

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from autoevals import init
1212
from autoevals.llm import Battle, Factuality, LLMClassifier, OpenAILLMClassifier, build_classification_tools
1313
from autoevals.oai import OpenAIV1Module, get_default_model
14-
from autoevals.thread_utils import compute_thread_template_vars
14+
from autoevals.thread_utils import compute_thread_template_vars, template_uses_thread_variables
1515

1616

1717
class TestModel(BaseModel):
@@ -96,6 +96,87 @@ def test_render_messages_with_thread_variables():
9696
assert rendered[6]["content"].startswith("Messages:\n- user: Hello, how are you?")
9797

9898

99+
def test_thread_template_detection_and_split_thread_vars():
100+
assert template_uses_thread_variables("{{thread_with_system}}")
101+
102+
full_thread = [
103+
{"role": "system", "content": "You are a helpful assistant."},
104+
{"role": "user", "content": "Hello, how are you?"},
105+
{"role": "assistant", "content": "I am doing well, thank you!"},
106+
]
107+
filtered_thread = full_thread[1:]
108+
109+
thread_vars = compute_thread_template_vars(filtered_thread, full_thread)
110+
111+
assert len(thread_vars["thread"]) == 2
112+
assert len(thread_vars["thread_with_system"]) == 3
113+
assert str(thread_vars["thread"][0]) == "user: Hello, how are you?"
114+
assert str(thread_vars["thread_with_system"][0]) == "system: You are a helpful assistant."
115+
assert thread_vars["thread_count"] == 2
116+
117+
118+
class _FakeTrace:
119+
def __init__(self, thread):
120+
self._thread = thread
121+
122+
async def get_thread(self, options=None):
123+
del options
124+
return self._thread
125+
126+
127+
def test_llm_classifier_request_args_keep_thread_filtered_and_thread_with_system_unfiltered():
128+
system_marker = "PY_AUTOEVALS_SYSTEM_MARKER"
129+
trace = _FakeTrace(
130+
[
131+
{"role": "system", "content": system_marker},
132+
{"role": "user", "content": "Hello"},
133+
{"role": "assistant", "content": "Hi there"},
134+
]
135+
)
136+
classifier = LLMClassifier(
137+
"test",
138+
"Filtered thread:\n{{thread}}\n\nFull thread:\n{{thread_with_system}}",
139+
{"Yes": 1, "No": 0},
140+
use_cot=False,
141+
)
142+
143+
request_args = classifier._request_args(output="", expected="", trace=trace)
144+
rendered_prompt = request_args["messages"][0]["content"]
145+
146+
filtered_thread, full_thread = rendered_prompt.split("\n\nFull thread:\n", 1)
147+
assert "Hello" in filtered_thread
148+
assert "Hi there" in filtered_thread
149+
assert system_marker not in filtered_thread
150+
assert system_marker in full_thread
151+
152+
153+
@pytest.mark.asyncio
154+
async def test_llm_classifier_request_args_async_keep_thread_filtered_and_thread_with_system_unfiltered():
155+
system_marker = "PY_AUTOEVALS_SYSTEM_MARKER_ASYNC"
156+
trace = _FakeTrace(
157+
[
158+
{"role": "system", "content": system_marker},
159+
{"role": "user", "content": "Hello"},
160+
{"role": "assistant", "content": "Hi there"},
161+
]
162+
)
163+
classifier = LLMClassifier(
164+
"test",
165+
"Filtered thread:\n{{thread}}\n\nFull thread:\n{{thread_with_system}}",
166+
{"Yes": 1, "No": 0},
167+
use_cot=False,
168+
)
169+
170+
request_args = await classifier._request_args_async(output="", expected="", trace=trace)
171+
rendered_prompt = request_args["messages"][0]["content"]
172+
173+
filtered_thread, full_thread = rendered_prompt.split("\n\nFull thread:\n", 1)
174+
assert "Hello" in filtered_thread
175+
assert "Hi there" in filtered_thread
176+
assert system_marker not in filtered_thread
177+
assert system_marker in full_thread
178+
179+
99180
def test_openai():
100181
e = OpenAILLMClassifier(
101182
"title",

py/autoevals/thread_utils.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
THREAD_VARIABLE_NAMES = [
1616
"thread",
17+
"thread_with_system",
1718
"thread_count",
1819
"first_message",
1920
"last_message",
@@ -245,8 +246,15 @@ def _to_renderable_message_array(messages: list[Any]) -> RenderableMessageArray:
245246
return RenderableMessageArray(wrapped)
246247

247248

248-
def compute_thread_template_vars(thread: list[Any]) -> dict[str, Any]:
249+
def compute_thread_template_vars(thread: list[Any], thread_with_system: list[Any] | None = None) -> dict[str, Any]:
249250
renderable_thread = _to_renderable_message_array(thread) if is_llm_message_array(thread) else thread
251+
if thread_with_system is None:
252+
thread_with_system = thread
253+
renderable_thread_with_system = (
254+
_to_renderable_message_array(thread_with_system)
255+
if is_llm_message_array(thread_with_system)
256+
else thread_with_system
257+
)
250258

251259
first_message = renderable_thread[0] if len(renderable_thread) > 0 else None
252260
last_message = renderable_thread[-1] if len(renderable_thread) > 0 else None
@@ -264,6 +272,7 @@ def compute_thread_template_vars(thread: list[Any]) -> dict[str, Any]:
264272

265273
return {
266274
"thread": renderable_thread,
275+
"thread_with_system": renderable_thread_with_system,
267276
"thread_count": len(thread),
268277
"first_message": first_message,
269278
"last_message": last_message,

0 commit comments

Comments
 (0)