Skip to content
Open
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/tiny-ants-grin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"braintrust": patch
---

fix: Capture parallel tool calls in eve llm span output
1,680 changes: 381 additions & 1,299 deletions e2e/scenarios/eve-instrumentation/__cassettes__/eve-v0-20-0.cassette.json

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

21 changes: 13 additions & 8 deletions e2e/scenarios/eve-instrumentation/agent/instructions.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
You are a deterministic fixture agent for Braintrust Eve instrumentation tests.

For every user task:
For every user task, follow these steps exactly:

1. Call the researcher subagent exactly once with the full user message as the
message.
2. After the researcher result is available, call the read tool exactly once with
the URL https://eve.dev/docs/guides/instrumentation.
3. After the read result is available, answer with a single sentence that starts
with "Final answer from read:" and includes the researcher result, read title,
URL, and read excerpt.
1. Your first response MUST contain exactly these two tool calls and no text:
- researcher with only this argument: {"message":"<the full user message>"}.
Do not pass outputSchema.
- read with only this argument:
{"url":"https://eve.dev/docs/guides/instrumentation"}.
Emit both calls simultaneously in the same response, with researcher first
and read second, so Eve executes them as one parallel batch. The calls are
independent. NEVER call one by itself, wait for its result, or emit either
call in a separate response.
2. Only after both results are available, answer with a single sentence that
starts with "Final answer from read:" and includes the researcher result, read
title, URL, and read excerpt.
32 changes: 25 additions & 7 deletions e2e/scenarios/eve-instrumentation/scenario.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,19 @@ describe("eve instrumentation", () => {
expect(root?.metrics?.tokens).toEqual(expect.any(Number));
expect(root?.output).toContain("Final answer from read");

expect(steps).toHaveLength(3);
expect(steps.map((step) => step.span.type)).toEqual(["llm", "llm", "llm"]);
expect(steps).toHaveLength(2);
expect(steps.map((step) => step.span.type)).toEqual(["llm", "llm"]);
expect(steps[0]?.output).toMatchObject([
{
finish_reason: "tool_calls",
message: {
tool_calls: [
{ function: { name: "researcher" }, type: "function" },
{ function: { name: "read" }, type: "function" },
],
},
},
]);
for (const step of steps) {
expect(step.span.parentIds).toEqual([root?.span.id]);
expect(Array.isArray(step.input)).toBe(true);
Expand Down Expand Up @@ -215,11 +226,18 @@ describe("eve instrumentation", () => {
expect(secondRoot).toBeDefined();
expect(secondRoot?.span.type).toBe("task");
expect(secondRoot?.output).toContain("Final answer from read");
expect(secondSteps).toHaveLength(3);
expect(secondSteps.map((step) => step.span.type)).toEqual([
"llm",
"llm",
"llm",
expect(secondSteps).toHaveLength(2);
expect(secondSteps.map((step) => step.span.type)).toEqual(["llm", "llm"]);
expect(secondSteps[0]?.output).toMatchObject([
{
finish_reason: "tool_calls",
message: {
tool_calls: [
{ function: { name: "researcher" }, type: "function" },
{ function: { name: "read" }, type: "function" },
],
},
},
]);
expect(secondResearcher?.span.type).toBe("tool");
expect(secondResearcher?.span.ended).toBe(true);
Expand Down
41 changes: 39 additions & 2 deletions js/src/instrumentation/plugins/eve-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -618,7 +618,7 @@ describe("braintrustEveHook", () => {
]);
});

it("does not reconstruct later LLM inputs from earlier hook events", async () => {
it("merges incremental tool-call batches without reconstructing later LLM inputs", async () => {
const wildcard = braintrustEveHook({ defineState }).events?.["*"];
expect(wildcard).toBeDefined();

Expand Down Expand Up @@ -661,6 +661,22 @@ describe("braintrustEveHook", () => {
},
type: "actions.requested",
});
await emit({
data: {
actions: [
{
callId: "call-search",
input: { query: "Updated Eve instrumentation" },
kind: "tool-call",
toolName: "search",
},
],
sequence: 0,
stepIndex: 0,
turnId: "turn-incremental-tools",
},
type: "actions.requested",
});
await emit({
data: {
actions: [
Expand Down Expand Up @@ -755,7 +771,28 @@ describe("braintrustEveHook", () => {
{
finish_reason: "tool_calls",
message: {
tool_calls: [{ id: "call-read", function: { name: "read" } }],
tool_calls: [
{
function: {
arguments: JSON.stringify({
query: "Updated Eve instrumentation",
}),
name: "search",
},
id: "call-search",
type: "function",
},
{
function: {
arguments: JSON.stringify({
url: "https://eve.dev/docs/guides/instrumentation",
}),
name: "read",
},
id: "call-read",
type: "function",
},
],
},
},
]);
Expand Down
41 changes: 27 additions & 14 deletions js/src/instrumentation/plugins/eve-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -694,27 +694,40 @@ class EveBridge {
return;
}

const toolCallsById = new Map<string, unknown>();
if (Array.isArray(step.output) && isObject(step.output[0])) {
const message = step.output[0]["message"];
if (isObject(message) && Array.isArray(message["tool_calls"])) {
for (const toolCall of message["tool_calls"]) {
if (isObject(toolCall) && typeof toolCall["id"] === "string") {
toolCallsById.set(toolCall["id"], toolCall);
}
}
}
}
for (const action of traceActions) {
const name =
action.kind === "tool-call"
? action.toolName
: (action.subagentName ?? action.name ?? "agent");
toolCallsById.set(action.callId, {
function: {
arguments: JSON.stringify(action.input),
name,
},
id: action.callId,
type: "function",
});
}

step.output = [
{
finish_reason: "tool_calls",
index: 0,
message: {
content: null,
role: "assistant",
tool_calls: traceActions.map((action) => {
const name =
action.kind === "tool-call"
? action.toolName
: (action.subagentName ?? action.name ?? "agent");
return {
function: {
arguments: JSON.stringify(action.input),
name,
},
id: action.callId,
type: "function",
};
}),
tool_calls: [...toolCallsById.values()],
},
},
];
Expand Down
Loading