Skip to content

Commit 5f2f7ca

Browse files
committed
fix(agents): stop DatabricksAdapter duplicating answer text across tool calls (#421)
Claude on Databricks Model Serving emits a full draft answer ALONGSIDE its tool calls in the same turn, because the OpenAI-compatible layer lacks Claude's native tool-use prompt. DatabricksAdapter yielded every `delta.content` as a `message_delta` the instant it arrived, so in a ReAct loop the draft surfaced on every tool-calling step — the same answer appearing 3-4x, and (because consumeAdapterStream appends message_delta content) duplicated in the aggregated output too. Surface assistant text only on the terminal turn: - streamCompletion streams text live only when no tools are offered (such a turn is always terminal). When tools are present it buffers the text into fullText instead of yielding it. - run() emits the buffered text as a single message_delta on the terminal turn (no structured or text-encoded tool calls). Tool-calling turns keep their text in message history for model context but never surface it to the user. Tradeoff: with tools enabled, the final answer arrives as one delta rather than streaming token-by-token — there's no way to know a turn is terminal until its stream ends without tool calls. Pure-chat (no tools) still streams live. Tests: 2 new cases (text+tool-call in one turn; no duplication across multiple steps). Full appkit suite (2141) passes. Signed-off-by: MarioCadenas <MarioCadenas@users.noreply.github.com>
1 parent 494edcc commit 5f2f7ca

2 files changed

Lines changed: 128 additions & 2 deletions

File tree

packages/appkit/src/agents/databricks.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,15 @@ export class DatabricksAdapter implements AgentAdapter {
413413
yield* this.executeToolCalls(parsed, messages, context, nameToWire);
414414
continue;
415415
}
416+
// Terminal turn: no tool calls (structured or text-encoded), so `text`
417+
// is the assistant's final answer. When tools were offered,
418+
// streamCompletion buffered the text instead of streaming it live (to
419+
// avoid duplicating it on tool-calling steps, #421) — surface it now,
420+
// exactly once. With no tools it was already streamed live, so emitting
421+
// again here would double it.
422+
if (tools.length > 0 && text) {
423+
yield { type: "message_delta", content: text };
424+
}
416425
break;
417426
}
418427

@@ -486,13 +495,14 @@ export class DatabricksAdapter implements AgentAdapter {
486495
{ text: string; toolCalls: OpenAIToolCall[] },
487496
unknown
488497
> {
498+
const hasTools = tools.length > 0;
489499
const body: Record<string, unknown> = {
490500
messages,
491501
stream: true,
492502
max_tokens: this.maxTokens,
493503
};
494504

495-
if (tools.length > 0) {
505+
if (hasTools) {
496506
body.tools = tools;
497507
}
498508

@@ -573,7 +583,18 @@ export class DatabricksAdapter implements AgentAdapter {
573583
this.maxStreamTextChars,
574584
);
575585
fullText += content;
576-
yield { type: "message_delta" as const, content };
586+
// Stream text live only when no tools are offered: such a turn is
587+
// always terminal, so its text is the final answer. When tools are
588+
// available the same turn may ALSO produce tool calls — Claude on
589+
// Databricks Model Serving emits a full draft answer alongside its
590+
// tool calls because the OpenAI-compatible layer lacks Claude's
591+
// native tool-use prompt. Emitting that live would surface the draft
592+
// on every ReAct step and duplicate it 3-4x (#421). Instead we buffer
593+
// it into `fullText`; run() surfaces the text once, only on the
594+
// terminal (no-tool) turn.
595+
if (!hasTools) {
596+
yield { type: "message_delta" as const, content };
597+
}
577598
}
578599

579600
const toolCallsRaw = deltaUnknown.tool_calls;

packages/appkit/src/agents/tests/databricks.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,111 @@ describe("DatabricksAdapter", () => {
252252
expect(mockAuthenticate).toHaveBeenCalledTimes(2);
253253
});
254254

255+
test("suppresses draft text emitted alongside tool calls, surfacing the final answer once (#421)", async () => {
256+
const executeTool = vi.fn().mockResolvedValue([{ answer: 42 }]);
257+
const draft = "The answer is 42.";
258+
259+
let callCount = 0;
260+
globalThis.fetch = vi.fn().mockImplementation(() => {
261+
callCount++;
262+
if (callCount === 1) {
263+
// A single turn that contains BOTH a full draft answer AND a tool call —
264+
// the shape Claude produces on Databricks Model Serving. The draft must
265+
// not reach the consumer, or it duplicates on every ReAct step.
266+
return Promise.resolve({
267+
ok: true,
268+
body: createReadableStream([
269+
textDelta(draft),
270+
toolCallDelta(
271+
0,
272+
"call_1",
273+
"analytics__query",
274+
'{"query":"SELECT 1"}',
275+
),
276+
sseChunk("[DONE]"),
277+
]),
278+
});
279+
}
280+
// Final turn: the real answer, no tool calls.
281+
return Promise.resolve({
282+
ok: true,
283+
body: createReadableStream([textDelta(draft), sseChunk("[DONE]")]),
284+
});
285+
});
286+
287+
const adapter = createAdapter();
288+
const events: AgentEvent[] = [];
289+
for await (const event of adapter.run(
290+
{
291+
messages: createTestMessages(),
292+
tools: createTestTools(),
293+
threadId: "t1",
294+
},
295+
{ executeTool },
296+
)) {
297+
events.push(event);
298+
}
299+
300+
// The draft on the tool-calling turn is suppressed; the answer appears once.
301+
const messageDeltas = events.filter((e) => e.type === "message_delta");
302+
expect(messageDeltas).toEqual([{ type: "message_delta", content: draft }]);
303+
304+
// The tool still ran (the draft was only suppressed from user-facing output).
305+
expect(executeTool).toHaveBeenCalledWith("analytics.query", {
306+
query: "SELECT 1",
307+
});
308+
});
309+
310+
test("does not duplicate the answer across multiple tool-calling steps (#421)", async () => {
311+
const executeTool = vi.fn().mockResolvedValue([{ ok: true }]);
312+
const draft = "Based on the data, revenue grew 12%.";
313+
314+
let callCount = 0;
315+
globalThis.fetch = vi.fn().mockImplementation(() => {
316+
callCount++;
317+
if (callCount <= 2) {
318+
// Two tool-calling turns, each leaking the full draft answer.
319+
return Promise.resolve({
320+
ok: true,
321+
body: createReadableStream([
322+
textDelta(draft),
323+
toolCallDelta(
324+
0,
325+
`call_${callCount}`,
326+
"analytics__query",
327+
'{"query":"SELECT 1"}',
328+
),
329+
sseChunk("[DONE]"),
330+
]),
331+
});
332+
}
333+
// Terminal turn with the final answer.
334+
return Promise.resolve({
335+
ok: true,
336+
body: createReadableStream([textDelta(draft), sseChunk("[DONE]")]),
337+
});
338+
});
339+
340+
const adapter = createAdapter();
341+
const events: AgentEvent[] = [];
342+
for await (const event of adapter.run(
343+
{
344+
messages: createTestMessages(),
345+
tools: createTestTools(),
346+
threadId: "t1",
347+
},
348+
{ executeTool },
349+
)) {
350+
events.push(event);
351+
}
352+
353+
const answerDeltas = events.filter(
354+
(e) => e.type === "message_delta" && e.content === draft,
355+
);
356+
expect(answerDeltas).toHaveLength(1);
357+
expect(executeTool).toHaveBeenCalledTimes(2);
358+
});
359+
255360
describe("Vertex/Gemini thoughtSignature pass-through", () => {
256361
// Vertex AI's OpenAI-compatible surface attaches `thoughtSignature`
257362
// on every function call emitted by Gemini 2.x/3.x models. The next

0 commit comments

Comments
 (0)