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
7 changes: 5 additions & 2 deletions src/adapters/_template/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export class TemplateAdapter implements LlmAdapter<ApiKeyAuth> {
appendAssistantToolCall(
_history: Message[],
_toolCalls: ReadonlyArray<ToolCallEvent>,
_assistantText?: string,
): Message[] {
// TODO: append an assistant turn containing the given tool calls to
// history, in the vendor's native shape. This turn must precede the
Expand All @@ -72,10 +73,12 @@ export class TemplateAdapter implements LlmAdapter<ApiKeyAuth> {
// ...history,
// {
// role: 'assistant',
// content: '',
// content: assistantText ?? '',
// vendorRaw: {
// role: 'assistant',
// content: null,
// // text the model emitted before the call — carry it, or the
// // model repeats itself on the post-tool turn
// content: assistantText || null,
// tool_calls: toolCalls.map((tc) => ({
// id: tc.id,
// type: 'function',
Expand Down
22 changes: 15 additions & 7 deletions src/adapters/anthropic/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,21 +280,29 @@ export class AnthropicAdapter implements LlmAdapter<ApiKeyAuth> {
appendAssistantToolCall(
history: Message[],
toolCalls: ReadonlyArray<ToolCallEvent>,
assistantText?: string,
): Message[] {
// A text block may precede tool_use blocks in the same assistant turn.
// Anthropic rejects an empty text block, so it is only included when the
// model actually said something before calling the tool.
const text = assistantText?.trim() ? assistantText : null;
const wireMessage = {
role: 'assistant',
content: toolCalls.map((tc) => ({
type: 'tool_use',
id: tc.id,
name: tc.name,
input: tc.arguments ?? {},
})),
content: [
...(text ? [{ type: 'text', text }] : []),
...toolCalls.map((tc) => ({
type: 'tool_use',
id: tc.id,
name: tc.name,
input: tc.arguments ?? {},
})),
],
};
return [
...history,
{
role: 'assistant',
content: '',
content: text ?? '',
vendorRaw: wireMessage,
},
];
Expand Down
3 changes: 2 additions & 1 deletion src/adapters/azure-openai/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,9 @@ export class AzureOpenAIAdapter implements LlmAdapter<AzureOpenAIApiKeyAuth> {
appendAssistantToolCall(
history: Message[],
toolCalls: ReadonlyArray<ToolCallEvent>,
assistantText?: string,
): Message[] {
return appendOpenAIAssistantToolCall(history, toolCalls);
return appendOpenAIAssistantToolCall(history, toolCalls, assistantText);
}

appendToolResult(history: Message[], toolCallId: string, result: unknown): Message[] {
Expand Down
23 changes: 15 additions & 8 deletions src/adapters/bedrock/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,22 +298,29 @@ export class BedrockAdapter implements LlmAdapter<BedrockAuthSpec> {
appendAssistantToolCall(
history: Message[],
toolCalls: ReadonlyArray<ToolCallEvent>,
assistantText?: string,
): Message[] {
// Converse allows a text block before toolUse blocks in the same turn;
// an empty one is rejected, so it is only included when non-blank.
const text = assistantText?.trim() ? assistantText : null;
const wireMessage = {
role: 'assistant',
content: toolCalls.map((tc) => ({
toolUse: {
toolUseId: tc.id,
name: tc.name,
input: tc.arguments ?? {},
},
})),
content: [
...(text ? [{ text }] : []),
...toolCalls.map((tc) => ({
toolUse: {
toolUseId: tc.id,
name: tc.name,
input: tc.arguments ?? {},
},
})),
],
};
return [
...history,
{
role: 'assistant',
content: '',
content: text ?? '',
vendorRaw: wireMessage,
},
];
Expand Down
20 changes: 13 additions & 7 deletions src/adapters/google/_streaming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,21 +182,27 @@ export async function* streamFromGemini(
export function appendGeminiAssistantToolCall(
history: Message[],
toolCalls: ReadonlyArray<Extract<LlmEvent, { type: 'toolCall' }>>,
assistantText?: string,
): Message[] {
// A text part may precede functionCall parts in the same model turn.
const text = assistantText?.trim() ? assistantText : null;
const wireMessage = {
role: 'model',
parts: toolCalls.map((tc) => ({
functionCall: {
name: parseToolName(tc.id),
args: tc.arguments ?? {},
},
})),
parts: [
...(text ? [{ text }] : []),
...toolCalls.map((tc) => ({
functionCall: {
name: parseToolName(tc.id),
args: tc.arguments ?? {},
},
})),
],
};
return [
...history,
{
role: 'assistant',
content: '',
content: text ?? '',
vendorRaw: wireMessage,
},
];
Expand Down
3 changes: 2 additions & 1 deletion src/adapters/google/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,9 @@ export class GoogleAdapter implements LlmAdapter<GoogleApiKeyAuth> {
appendAssistantToolCall(
history: Message[],
toolCalls: ReadonlyArray<ToolCallEvent>,
assistantText?: string,
): Message[] {
return appendGeminiAssistantToolCall(history, toolCalls);
return appendGeminiAssistantToolCall(history, toolCalls, assistantText);
}

appendToolResult(history: Message[], toolCallId: string, result: unknown): Message[] {
Expand Down
6 changes: 4 additions & 2 deletions src/adapters/openai/_streaming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,10 +332,12 @@ export async function* streamFromOpenAI(
export function appendOpenAIAssistantToolCall(
history: Message[],
toolCalls: ReadonlyArray<Extract<LlmEvent, { type: 'toolCall' }>>,
assistantText?: string,
): Message[] {
const text = assistantText?.trim() ? assistantText : null;
const wireMessage = {
role: 'assistant',
content: null,
content: text,
tool_calls: toolCalls.map((tc) => ({
id: tc.id,
type: 'function',
Expand All @@ -351,7 +353,7 @@ export function appendOpenAIAssistantToolCall(
...history,
{
role: 'assistant',
content: '',
content: text ?? '',
vendorRaw: wireMessage,
},
];
Expand Down
3 changes: 2 additions & 1 deletion src/adapters/openai/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,9 @@ export class OpenAIAdapter implements LlmAdapter<ApiKeyAuth> {
appendAssistantToolCall(
history: Message[],
toolCalls: ReadonlyArray<ToolCallEvent>,
assistantText?: string,
): Message[] {
return appendOpenAIAssistantToolCall(history, toolCalls);
return appendOpenAIAssistantToolCall(history, toolCalls, assistantText);
}

appendToolResult(history: Message[], toolCallId: string, result: unknown): Message[] {
Expand Down
3 changes: 2 additions & 1 deletion src/adapters/vertex-gemini/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,9 @@ export class VertexGeminiAdapter implements LlmAdapter<VertexServiceAccountAuth>
appendAssistantToolCall(
history: Message[],
toolCalls: ReadonlyArray<ToolCallEvent>,
assistantText?: string,
): Message[] {
return appendGeminiAssistantToolCall(history, toolCalls);
return appendGeminiAssistantToolCall(history, toolCalls, assistantText);
}

appendToolResult(history: Message[], toolCallId: string, result: unknown): Message[] {
Expand Down
3 changes: 2 additions & 1 deletion src/adapters/vertex-openai/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,9 @@ export class VertexOpenAIAdapter implements LlmAdapter<VertexServiceAccountAuth>
appendAssistantToolCall(
history: Message[],
toolCalls: ReadonlyArray<ToolCallEvent>,
assistantText?: string,
): Message[] {
return appendOpenAIAssistantToolCall(history, toolCalls);
return appendOpenAIAssistantToolCall(history, toolCalls, assistantText);
}

appendToolResult(history: Message[], toolCallId: string, result: unknown): Message[] {
Expand Down
5 changes: 4 additions & 1 deletion src/test-kit/fake-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,16 @@ export class FakeAdapter implements LlmAdapter<ApiKeyAuth> {
appendAssistantToolCall(
history: Message[],
toolCalls: ReadonlyArray<ToolCallEvent>,
assistantText?: string,
): Message[] {
const text = assistantText?.trim() ? assistantText : null;
return [
...history,
{
role: 'assistant',
content: '',
content: text ?? '',
vendorRaw: {
...(text ? { text } : {}),
role: 'assistant',
toolCalls: toolCalls.map((tc) => ({
id: tc.id,
Expand Down
60 changes: 60 additions & 0 deletions src/test-kit/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,66 @@ export function runContractTests(harness: ContractHarness): void {
expect(appended.role).toBe('assistant');
expect(appended.vendorRaw).toBeDefined();
});

it('[22] appendAssistantToolCall carries pre-tool assistant text onto the wire', async () => {
await harness.mockScenario('tool-call');
const adapter = await init(harness);
const first = await drainStream(adapter, {
model: harness.toolCapableModel,
messages: [{ role: 'user', content: 'go' }],
tools: [
{ name: 'test_tool', description: 'x', parameters: { type: 'object' } },
],
});
const tc = first.find(
(e): e is Extract<LlmEvent, { type: 'toolCall' }> => e.type === 'toolCall',
);
expect(tc).toBeDefined();

// Text the model emitted before calling the tool. Dropping it makes the
// model believe it never spoke, so it repeats itself after the tool
// result — the caller hears the same sentence twice.
const preToolText = 'Checking that now.';
const history = adapter.appendAssistantToolCall(
[{ role: 'user', content: 'go' }],
[tc!],
preToolText,
);
const appended = history[history.length - 1]!;
expect(appended.content).toBe(preToolText);
// Vendors encode it differently (a string, a text block, a text part),
// so assert on the serialized wire message rather than a fixed shape.
expect(JSON.stringify(appended.vendorRaw)).toContain(preToolText);
});

it('[23] appendAssistantToolCall omits empty pre-tool text from the wire', async () => {
await harness.mockScenario('tool-call');
const adapter = await init(harness);
const first = await drainStream(adapter, {
model: harness.toolCapableModel,
messages: [{ role: 'user', content: 'go' }],
tools: [
{ name: 'test_tool', description: 'x', parameters: { type: 'object' } },
],
});
const tc = first.find(
(e): e is Extract<LlmEvent, { type: 'toolCall' }> => e.type === 'toolCall',
);
expect(tc).toBeDefined();

// Anthropic and Bedrock reject an empty text block, so a blank string
// must not produce one. Omitting the argument entirely is the same case.
for (const blank of [undefined, '', ' ']) {
const history = adapter.appendAssistantToolCall(
[{ role: 'user', content: 'go' }],
[tc!],
blank,
);
const appended = history[history.length - 1]!;
expect(appended.content).toBe('');
expect(JSON.stringify(appended.vendorRaw)).not.toContain('"text":""');
}
});
});
}

Expand Down
7 changes: 7 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,10 +408,17 @@ export interface LlmAdapter<A extends AuthSpec = AuthSpec> {
* content:[{type:'tool_use',...}]}`; Google: `{role:'model',
* parts:[{functionCall,...}]}`; etc.). The returned `Message` carries the
* wire shape in `vendorRaw` and is round-trippable through `stream()`.
*
* `assistantText` is any text the model emitted BEFORE the tool calls in the
* same turn. Vendors allow it alongside the call (OpenAI: `content`;
* Anthropic and Google: a text block/part preceding the call), and dropping
* it makes the model believe it never spoke — on the post-tool turn it
* repeats itself. Adapters MUST carry it onto the wire when non-empty.
*/
appendAssistantToolCall(
history: Message[],
toolCalls: ReadonlyArray<ToolCallEvent>,
assistantText?: string,
): Message[];

/**
Expand Down
Loading