Skip to content

Commit e99dddb

Browse files
authored
ollama provider (#22)
* feat: enhance agent functionality with ollama * fix test and tsc error * feat: add examples for ollama agent and embedding, and enhance openrouter reasoning
1 parent 983e026 commit e99dddb

File tree

20 files changed

+487
-252
lines changed

20 files changed

+487
-252
lines changed

bun.lock

Lines changed: 1 addition & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/fluent-ai/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/dist
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import z from "zod";
2+
import { agent, agentTool, inspectAgentStream, ollama } from "~/src/index";
3+
4+
const retrieveContext = async (args: { query: string }) => {
5+
await Bun.sleep(500); // Simulate latency
6+
7+
return {
8+
context:
9+
"The Eiffel Tower is a wrought-iron lattice tower on the Champ de Mars in Paris, France. It is named after the engineer Gustave Eiffel, whose company designed and built the tower from 1887 to 1889.",
10+
};
11+
};
12+
13+
const retrieveContextTool = agentTool("retrieve_context")
14+
.description("Retrieve information to help answer a query.")
15+
.input(
16+
z.object({
17+
query: z.string().describe("The query to retrieve context for."),
18+
}),
19+
)
20+
.execute(retrieveContext);
21+
22+
const chatAgent = agent("chat-agent")
23+
.model(ollama().chat("qwen3:1.7b"))
24+
.tool(retrieveContextTool)
25+
.instructions(
26+
() => `You have access to a tool that retrieves context.
27+
Use the tool to help answer user queries.`,
28+
);
29+
30+
const stream = chatAgent.generate(
31+
[
32+
{
33+
id: "1",
34+
role: "user",
35+
text: "Tell me about the Eiffel Tower.",
36+
},
37+
],
38+
{ maxSteps: 8 },
39+
);
40+
41+
await inspectAgentStream(stream);

packages/fluent-ai/examples/ollama-chat.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,30 @@ const models = await ollama().models().run();
55
console.log(models);
66

77
const response = await ollama()
8-
.chat(models[0].name)
8+
.chat(models[0].id)
99
.messages([
1010
{
1111
role: "user",
12-
content: "What is the capital of France?",
12+
text: "What is the capital of France?",
1313
},
1414
])
1515
.run();
1616

1717
console.log(response);
18+
19+
const streamResponse = await ollama()
20+
.chat(models[0].id)
21+
.messages([
22+
{
23+
role: "user",
24+
text: "What is the capital of Spain?",
25+
},
26+
])
27+
.stream()
28+
.run();
29+
30+
for await (const chunk of streamResponse) {
31+
if (chunk.message?.text) {
32+
process.stdout.write(chunk.message.text);
33+
}
34+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { ollama } from "../src";
2+
3+
const result = await ollama()
4+
.embedding("embeddinggemma")
5+
.input(["Why is the sky blue?", "Why is the grass green?"])
6+
.run();
7+
8+
console.log(result.embeddings);

packages/fluent-ai/examples/openrouter-chat.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ const job: Job = {
77
input: {
88
model: "google/gemini-2.5-flash",
99
messages: [
10-
{ role: "system", content: "You are a helpful assistant." },
11-
{ role: "user", content: "Hi" },
10+
{ role: "system", text: "You are a helpful assistant." },
11+
{ role: "user", text: "Hi" },
1212
],
1313
},
1414
};
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { openrouter } from "~/src/index";
2+
3+
const stream = await openrouter()
4+
.chat("deepseek/deepseek-r1")
5+
.messages([
6+
{
7+
role: "user",
8+
text: "How would you build the world's tallest skyscraper?",
9+
},
10+
])
11+
.stream()
12+
.run();
13+
14+
for await (const chunk of stream) {
15+
console.log(JSON.stringify(chunk, null, 2));
16+
}

packages/fluent-ai/package.json

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
".": {
1010
"import": "./dist/index.js",
1111
"types": "./dist/index.d.ts"
12-
}
12+
},
13+
"./src": "./src/index.ts"
1314
},
1415
"files": [
1516
"src",
@@ -19,10 +20,6 @@
1920
"build": "bun run build.ts",
2021
"prepublishOnly": "rm -rf dist && bun run build"
2122
},
22-
"dependencies": {
23-
"eventsource-parser": "^3.0.6",
24-
"partial-json": "^0.1.7"
25-
},
2623
"keywords": [
2724
"ai",
2825
"openai",

packages/fluent-ai/src/agent/agent.ts

Lines changed: 101 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import { z } from "zod";
2-
import { convertMessagesForChatCompletion } from "~/src/agent/message";
32
import {
43
agentToolSchema,
54
type AgentToolBuilder,
65
type AgentTool,
76
} from "~/src/agent/tool";
8-
import type { Message } from "~/src/job/schema";
7+
import type {
8+
Message,
9+
ToolMessage,
10+
MessageChunk,
11+
AssistantMessage,
12+
} from "~/src/job/schema";
913
import type { ChatBuilder } from "~/src/builder/chat";
1014

1115
export const agentSchema = z.object({
@@ -14,7 +18,30 @@ export const agentSchema = z.object({
1418
tools: z.array(agentToolSchema),
1519
});
1620

17-
interface GenerateOptions {
21+
interface ChunkEvent {
22+
type: "chunk";
23+
chunk: {
24+
text?: string;
25+
reasoning?: string;
26+
};
27+
}
28+
29+
interface ToolEvent {
30+
type: "tool";
31+
tool: {
32+
name: string;
33+
args: any;
34+
result?: any;
35+
error?: any;
36+
};
37+
}
38+
39+
interface MessageEvent {
40+
type: "message";
41+
message: Message;
42+
}
43+
44+
export interface AgentGenerateOptions {
1845
maxSteps: number;
1946
}
2047

@@ -46,28 +73,27 @@ export class Agent<TContext = any> {
4673
generate = async function* (
4774
this: Agent<TContext>,
4875
initialMessages: Message[],
49-
options: GenerateOptions,
76+
options: AgentGenerateOptions,
5077
context?: TContext,
5178
) {
5279
const body = agentSchema.parse(this.body);
5380

54-
let shouldBreak = false;
81+
let shouldFinish = false;
5582
let newMessages: Message[] = [];
5683
for (let iteration = 0; iteration < options.maxSteps; iteration++) {
57-
if (shouldBreak) {
84+
if (shouldFinish) {
5885
break;
5986
}
6087

6188
const instructions =
6289
typeof body.instructions === "function"
63-
? body.instructions()
90+
? body.instructions() // TODO: more context
6491
: body.instructions;
65-
const allMessages = initialMessages.concat(newMessages);
66-
const convertedMessages = convertMessagesForChatCompletion(allMessages);
67-
const messages = [{ role: "system", content: instructions }].concat(
68-
convertedMessages as any,
92+
const systemMessage = { role: "system", text: instructions };
93+
const messages = ([systemMessage] as Message[]).concat(
94+
initialMessages,
95+
newMessages,
6996
);
70-
// TODO: agent tool vs chat tool
7197
const tools = body.tools.map((tool) => ({
7298
name: tool.name,
7399
description: tool.description,
@@ -78,82 +104,87 @@ export class Agent<TContext = any> {
78104
.stream()
79105
.run();
80106

81-
let totalText = "";
82-
for await (const chunk of result) {
83-
const delta = chunk.raw.choices[0].delta;
107+
let newAssistantMessage: AssistantMessage = {
108+
role: "assistant",
109+
text: "",
110+
reasoning: "",
111+
};
84112

85-
// TODO: tool calls with content??
86-
if (delta.tool_calls) {
87-
// TODO: tool call with content
88-
// TODO: tool call with input streaming
89-
// TODO: support multiple tool calls
90-
const toolCall = delta.tool_calls[0];
91-
const toolName = toolCall.function.name;
92-
const input = JSON.parse(toolCall.function.arguments); // TODO: parsing error handling
113+
for await (const chunk of result as AsyncIterable<MessageChunk>) {
114+
if (chunk.toolCalls) {
115+
// existing assistant message chunked out before tool call
116+
if (newAssistantMessage.text || newAssistantMessage.reasoning) {
117+
yield {
118+
type: "message",
119+
message: newAssistantMessage,
120+
} as MessageEvent;
121+
newMessages.push(newAssistantMessage);
122+
newAssistantMessage = {
123+
role: "assistant",
124+
text: "",
125+
reasoning: "",
126+
};
127+
}
93128

94-
const agentTool = body.tools.find((t) => t.name === toolName);
129+
const toolCall = chunk.toolCalls[0];
130+
const { name, arguments: args } = toolCall.function;
131+
const agentTool = body.tools.find((t) => t.name === name);
95132
if (!agentTool) {
96-
throw new Error(`Unknown tool: ${toolName}`);
133+
throw new Error(`Unknown tool: ${name}`);
97134
}
98135

99-
const toolPart = {
100-
type: "tool-" + toolName,
101-
toolCallId: toolCall.id,
102-
input: input,
103-
};
104-
105-
yield { type: "tool-call-input", data: toolPart };
106-
107-
let output = null;
108-
let outputError = null;
136+
yield { type: "tool", tool: { name, args } };
109137

138+
let result = null;
139+
let error = null;
110140
try {
111-
output = await agentTool.execute(input, context!);
141+
result = await agentTool.execute(args, context!);
112142
} catch (err) {
113-
outputError = (err as Error).message;
143+
error = (err as Error).message;
114144
}
115145

116-
if (outputError) {
117-
yield {
118-
type: "tool-call-output",
119-
data: { ...toolPart, outputError },
120-
};
121-
} else {
122-
yield { type: "tool-call-output", data: { ...toolPart, output } };
123-
}
146+
yield {
147+
type: "tool",
148+
tool: { name, args, result, error },
149+
} as ToolEvent;
124150

125-
const newMessage: Message = {
151+
const newMessage: ToolMessage = {
126152
role: "tool",
127-
parts: [
128-
{
129-
type: `tool-${toolName}`,
130-
toolCallId: toolCall.id,
131-
input: input,
132-
output: output,
133-
outputError: outputError,
134-
},
135-
],
153+
text: "",
154+
content: {
155+
callId: toolCall.id,
156+
name: name,
157+
args: args,
158+
result: result,
159+
error: error,
160+
},
136161
};
137162

138-
yield { type: "message-created", data: newMessage };
163+
yield { type: "message", message: newMessage } as MessageEvent;
139164
newMessages.push(newMessage);
140-
} else if (delta.content) {
141-
const text = delta.content as string;
142-
yield { type: "text-delta", data: { text } };
143-
totalText += text;
144-
shouldBreak = true;
165+
shouldFinish = false;
166+
} else if (chunk.text || chunk.reasoning) {
167+
yield {
168+
type: "chunk",
169+
chunk: {
170+
text: chunk.text,
171+
reasoning: chunk.reasoning,
172+
},
173+
} as ChunkEvent;
174+
175+
if (chunk.text) {
176+
newAssistantMessage.text += chunk.text;
177+
}
178+
if (chunk.reasoning) {
179+
newAssistantMessage.reasoning += chunk.reasoning;
180+
}
181+
shouldFinish = true;
145182
}
146183
}
147184

148-
if (totalText.trim()) {
149-
const newMessage: Message = {
150-
role: "assistant",
151-
parts: [{ type: "text", text: totalText.trim() }],
152-
};
153-
154-
yield { type: "message-created", data: newMessage };
155-
newMessages.push(newMessage);
156-
shouldBreak = true;
185+
if (newAssistantMessage.text || newAssistantMessage.reasoning) {
186+
yield { type: "message", message: newAssistantMessage } as MessageEvent;
187+
newMessages.push(newAssistantMessage);
157188
}
158189
}
159190
};

0 commit comments

Comments
 (0)