Skip to content
Draft
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
60 changes: 60 additions & 0 deletions docs/core/model.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ AgentScope provides a unified interface for multiple language model providers:
<Card title="DashScope" icon="aliyun">
Qwen models from Alibaba Cloud
</Card>
<Card title="Gemini" icon="google">
Native Gemini Developer API with function calling
</Card>
<Card title="Ollama" icon="server">
Local models via Ollama
</Card>
Expand Down Expand Up @@ -116,6 +119,63 @@ const model = new DashScopeChatModel({
});
```

### Gemini

```typescript
import { GeminiChatModel } from '@agentscope-ai/agentscope/model';
import { createMsg, TextBlock } from '@agentscope-ai/agentscope/message';

const model = new GeminiChatModel({
modelName: 'gemini-3.8-flash',
apiKey: process.env.GEMINI_API_KEY!,
stream: false,
presetGenParams: {
temperature: 0.7,
maxOutputTokens: 1024,
},
});

const response = await model.call({
messages: [
createMsg({
name: 'user',
role: 'user',
content: [TextBlock({ text: 'Explain how a rainbow forms.' })],
}),
],
});
```

This adapter uses the Gemini Developer API's native `generateContent` and
`streamGenerateContent` endpoints. `baseURL` can override the API root (including
its version); Vertex AI authentication is not supported. No Google SDK dependency
is required. A `GeminiChatFormatter` is provided automatically and can also be
imported from `@agentscope-ai/agentscope/formatter`.

Pass native camelCase generation settings in `presetGenParams`, or override them
per call using `generationConfig`. Per-call `safetySettings` and `signal`
(`AbortSignal`) are also supported. The adapter uses the first response candidate.

Function calling supports `auto`, `none`, `required`, and a specific function name.
Tool schemas use Gemini's `parametersJsonSchema` field. Parallel function calls are
returned as separate pending tool-call blocks; their results are grouped into one
user turn on the following request. `callStructured` uses the base class's named
function-calling mechanism. Function arguments are received as complete objects;
experimental partial function-argument streaming is not supported.

With `stream: true` (the default), `call()` returns an async generator. Each yield
contains incremental content; the generator's return value contains the complete
response and the final token usage, including thought tokens. The `Agent` consumes
both automatically. When using the generator directly, read it with `.next()` if
you need the return value; a `for await` loop only exposes the deltas.

The formatter accepts inline base64 data and Gemini-compatible file URIs.
HTTP image URLs are not downloaded automatically: use the Gemini Files API or
supply base64 data. Signed response parts retain an optional `thought_signature`
field through message schema parsing. Keep these blocks intact in saved history;
dropping their signatures can cause Gemini to reject subsequent tool turns.
Unsigned thought summaries are omitted from replay.

### Ollama

```typescript
Expand Down
125 changes: 125 additions & 0 deletions packages/agentscope/src/formatter/gemini-chat-formatter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { GeminiChatFormatter } from './gemini-chat-formatter';
import {
createMsg,
TextBlock,
DataBlock,
ThinkingBlock,
HintBlock,
ToolCallBlock,
ToolResultBlock,
parseContentBlock,
} from '../message';

const formatter = new GeminiChatFormatter();

describe('GeminiChatFormatter', () => {
test('formats multimodal input and hints without changing the conversation', async () => {
const msgs = [
createMsg({
name: 'user',
role: 'user',
content: [
TextBlock({ text: 'Describe' }),
DataBlock({
source: { type: 'base64', data: 'aGVsbG8=', media_type: 'image/png' },
}),
DataBlock({
source: {
type: 'url',
url: 'https://generativelanguage.googleapis.com/v1beta/files/sample',
media_type: 'video/mp4',
},
}),
],
}),
createMsg({
name: 'agent',
role: 'assistant',
content: [HintBlock({ hint: 'A hint' })],
}),
];
const before = JSON.stringify(msgs);
expect(await formatter.format({ msgs })).toEqual([
{
role: 'user',
parts: [
{ text: 'Describe' },
{ inlineData: { mimeType: 'image/png', data: 'aGVsbG8=' } },
{
fileData: {
mimeType: 'video/mp4',
fileUri:
'https://generativelanguage.googleapis.com/v1beta/files/sample',
},
},
{ text: 'A hint' },
],
},
]);
expect(JSON.stringify(msgs)).toBe(before);
});

test('retains signed thought parts and omits unsigned summaries', async () => {
const msg = createMsg({
name: 'agent',
role: 'assistant',
content: [
ThinkingBlock({ thinking: 'Unsigned summary' }),
ThinkingBlock({ thinking: 'Signed', thought_signature: 'sig' }),
],
});
expect(await formatter.format({ msgs: [msg] })).toEqual([
{ role: 'model', parts: [{ text: 'Signed', thought: true, thoughtSignature: 'sig' }] },
]);
});

test('rejects non-object function arguments instead of sending an invalid request', async () => {
const msg = createMsg({
name: 'agent',
role: 'assistant',
content: [ToolCallBlock({ id: 'call', name: 'weather', input: '[]' })],
});
await expect(formatter.format({ msgs: [msg] })).rejects.toThrow('JSON object');
});

test('converts tool failures into native error responses', async () => {
const msg = createMsg({
name: 'tools',
role: 'assistant',
content: [
ToolResultBlock({ id: 'call', name: 'weather', output: 'timeout', state: 'error' }),
],
});
expect(await formatter.format({ msgs: [msg] })).toEqual([
{
role: 'user',
parts: [
{
functionResponse: {
name: 'weather',
id: 'call',
response: { error: 'timeout' },
},
},
],
},
]);
});

test('preserves optional signatures on text, tool-call and data blocks during schema parsing', () => {
for (const block of [
TextBlock({ text: 'hello', thought_signature: 'text-sig' }),
ToolCallBlock({
id: 'call',
name: 'weather',
input: '{}',
thought_signature: 'call-sig',
}),
DataBlock({
source: { type: 'base64', data: 'aGVsbG8=', media_type: 'image/png' },
thought_signature: 'data-sig',
}),
])
expect(parseContentBlock(JSON.parse(JSON.stringify(block)))).toEqual(block);
});
});
119 changes: 119 additions & 0 deletions packages/agentscope/src/formatter/gemini-chat-formatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { FormatterBase } from './base';
import type { ContentBlock, DataBlock } from '../message/block';
import { getContentBlocks } from '../message/message';
import type { Msg } from '../message/message';

/** Gemini REST content part, including the signature needed for history replay. */
export interface GeminiPart {
text?: string;
thought?: boolean;
thoughtSignature?: string;
functionCall?: { name: string; args?: Record<string, unknown>; id?: string };
functionResponse?: { name: string; response: Record<string, unknown>; id?: string };
inlineData?: { mimeType: string; data: string };
fileData?: { mimeType: string; fileUri: string };
}

/** Converts AgentScope messages to Gemini's native contents format. */
export class GeminiChatFormatter extends FormatterBase {
/**
* Format messages without mutating the caller's conversation.
* @param root0
* @param root0.msgs
* @returns The formatted or accumulated result.
*/
async format({ msgs }: { msgs: Msg[] }): Promise<Record<string, unknown>[]> {
const result: { role: string; parts: GeminiPart[] }[] = [];
const append = (role: string, parts: GeminiPart[]) => {
if (!parts.length) return;
const last = result.at(-1);
// Parallel function responses must stay in one user turn.
if (last?.role === role) last.parts.push(...parts);
else result.push({ role, parts });
};
for (const msg of msgs) {
const role = msg.role === 'assistant' ? 'model' : msg.role;
for (const block of getContentBlocks(msg)) {
if (block.type === 'tool_result') {
const { text } = this.convertToolOutputToString(block.output, false);
append('user', [
{
functionResponse: {
name: block.name,
id: block.id,
response:
block.state === 'error' ? { error: text } : { output: text },
},
},
]);
} else if (block.type === 'hint') {
append(
'user',
typeof block.hint === 'string'
? [{ text: block.hint }]
: block.hint.map(b => this.formatPart(b)).filter(p => p !== null)
);
} else {
const part = this.formatPart(block);
if (part) append(role, [part]);
}
}
}
return result;
}

/**
* Format one content block and preserve its opaque provider signature.
* @param block
* @returns The formatted or accumulated result.
*/
private formatPart(block: ContentBlock): GeminiPart | null {
let part: GeminiPart;
switch (block.type) {
case 'text':
part = { text: block.text };
break;
case 'thinking':
// Unsigned thought summaries are not user-facing conversation text.
if (!block.thought_signature) return null;
part = { text: block.thinking, thought: true };
break;
case 'tool_call': {
const args: unknown = JSON.parse(block.input || '{}');
if (!args || typeof args !== 'object' || Array.isArray(args)) {
throw new Error(
`Gemini tool arguments for ${block.name} must be a JSON object`
);
}
part = {
functionCall: {
name: block.name,
args: args as Record<string, unknown>,
id: block.id,
},
};
break;
}
case 'data':
part = this.formatData(block);
break;
default:
return null;
}
if ('thought_signature' in block && typeof block.thought_signature === 'string') {
part.thoughtSignature = block.thought_signature;
}
return part;
}

/**
* Convert inline data or a Gemini Files API URI to a native data part.
* @param block
* @returns The formatted or accumulated result.
*/
private formatData(block: DataBlock): GeminiPart {
return block.source.type === 'base64'
? { inlineData: { mimeType: block.source.media_type, data: block.source.data } }
: { fileData: { mimeType: block.source.media_type, fileUri: block.source.url } };
}
}
1 change: 1 addition & 0 deletions packages/agentscope/src/formatter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export { DashScopeChatFormatter } from './dashscope-chat-formatter';
export { DeepSeekChatFormatter } from './deepseek-chat-formatter';
export { OllamaChatFormatter } from './ollama-chat-formatter';
export { OpenAIChatFormatter } from './openai-chat-formatter';
export { GeminiChatFormatter } from './gemini-chat-formatter';
15 changes: 15 additions & 0 deletions packages/agentscope/src/message/block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { _generateId, _generateTimestamp } from '../_utils/common';
import type { PermissionRule } from '../permission';

export interface TextBlock {
/** Opaque Gemini part signature, retained for conversation replay. */
thought_signature?: string;
type: 'text';
text: string;
id: string;
Expand Down Expand Up @@ -51,6 +53,8 @@ export interface HintBlock {
export type ToolCallState = 'pending' | 'asking' | 'allowed' | 'submitted' | 'finished';

export interface ToolCallBlock {
/** Opaque Gemini part signature, retained for conversation replay. */
thought_signature?: string;
type: 'tool_call';
name: string;
id: string;
Expand Down Expand Up @@ -91,6 +95,8 @@ export interface URLSource {
}

export interface DataBlock {
/** Opaque Gemini part signature, retained for conversation replay. */
thought_signature?: string;
type: 'data';
source: Base64Source | URLSource;
id: string;
Expand All @@ -113,6 +119,9 @@ export function TextBlock(
Partial<Pick<TextBlock, 'id' | 'created_at' | 'finished_at'>>
): TextBlock {
return {
...(input.thought_signature !== undefined
? { thought_signature: input.thought_signature }
: {}),
type: 'text',
text: input.text,
id: input.id ?? _generateId(),
Expand Down Expand Up @@ -176,6 +185,9 @@ export function DataBlock(
Partial<Pick<DataBlock, 'id' | 'name' | 'created_at' | 'finished_at'>>
): DataBlock {
return {
...(input.thought_signature !== undefined
? { thought_signature: input.thought_signature }
: {}),
type: 'data',
id: input.id ?? _generateId(),
source: input.source,
Expand Down Expand Up @@ -218,6 +230,9 @@ export function ToolCallBlock(
Partial<Pick<ToolCallBlock, 'state' | 'suggested_rules' | 'created_at' | 'finished_at'>>
): ToolCallBlock {
return {
...(input.thought_signature !== undefined
? { thought_signature: input.thought_signature }
: {}),
type: 'tool_call',
id: input.id,
name: input.name,
Expand Down
Loading