Skip to content

Commit 6236918

Browse files
committed
code abstraction for tool calling
1 parent acd4767 commit 6236918

File tree

6 files changed

+271
-62
lines changed

6 files changed

+271
-62
lines changed

tools/server/webui/src/components/ChatMessage.tsx

Lines changed: 83 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ interface SplitMessage {
99
content: PendingMessage['content'];
1010
thought?: string;
1111
isThinking?: boolean;
12+
toolOutput?: string;
13+
toolTitle?: string;
1214
}
1315

1416
export default function ChatMessage({
@@ -48,32 +50,71 @@ export default function ChatMessage({
4850
const nextSibling = siblingLeafNodeIds[siblingCurrIdx + 1];
4951
const prevSibling = siblingLeafNodeIds[siblingCurrIdx - 1];
5052

51-
// for reasoning model, we split the message into content and thought
52-
// TODO: implement this as remark/rehype plugin in the future
53-
const { content, thought, isThinking }: SplitMessage = useMemo(() => {
54-
if (msg.content === null || msg.role !== 'assistant') {
55-
return { content: msg.content };
56-
}
57-
let actualContent = '';
58-
let thought = '';
59-
let isThinking = false;
60-
let thinkSplit = msg.content.split('<think>', 2);
61-
actualContent += thinkSplit[0];
62-
while (thinkSplit[1] !== undefined) {
63-
// <think> tag found
64-
thinkSplit = thinkSplit[1].split('</think>', 2);
65-
thought += thinkSplit[0];
66-
isThinking = true;
67-
if (thinkSplit[1] !== undefined) {
68-
// </think> closing tag found
69-
isThinking = false;
70-
thinkSplit = thinkSplit[1].split('<think>', 2);
71-
actualContent += thinkSplit[0];
53+
// for reasoning model, we split the message into content, thought, and tool output
54+
const { content, thought, isThinking, toolOutput, toolTitle }: SplitMessage =
55+
useMemo(() => {
56+
if (msg.content === null || msg.role !== 'assistant') {
57+
return { content: msg.content };
7258
}
73-
}
74-
return { content: actualContent, thought, isThinking };
75-
}, [msg]);
59+
let currentContent = msg.content;
60+
let extractedThought: string | undefined = undefined;
61+
let isCurrentlyThinking = false;
62+
let extractedToolOutput: string | undefined = undefined;
63+
let extractedToolTitle: string | undefined = 'Tool Output';
7664

65+
// Process <think> tags
66+
const thinkParts = currentContent.split('<think>');
67+
currentContent = thinkParts[0];
68+
if (thinkParts.length > 1) {
69+
isCurrentlyThinking = true;
70+
const tempThoughtArray: string[] = [];
71+
for (let i = 1; i < thinkParts.length; i++) {
72+
const thinkSegment = thinkParts[i].split('</think>');
73+
tempThoughtArray.push(thinkSegment[0]);
74+
if (thinkSegment.length > 1) {
75+
isCurrentlyThinking = false; // Closing tag found
76+
currentContent += thinkSegment[1];
77+
}
78+
}
79+
extractedThought = tempThoughtArray.join('\n');
80+
}
81+
82+
// Process <tool> tags (after thoughts are processed)
83+
const toolParts = currentContent.split('<tool>');
84+
console.log(toolParts);
85+
currentContent = toolParts[0];
86+
if (toolParts.length > 1) {
87+
const tempToolOutputArray: string[] = [];
88+
for (let i = 1; i < toolParts.length; i++) {
89+
const toolSegment = toolParts[i].split('</tool>');
90+
const toolContent = toolSegment[0].trim();
91+
92+
const firstLineEnd = toolContent.indexOf('\n');
93+
if (firstLineEnd !== -1) {
94+
extractedToolTitle = toolContent.substring(0, firstLineEnd);
95+
tempToolOutputArray.push(
96+
toolContent.substring(firstLineEnd + 1).trim()
97+
);
98+
} else {
99+
// If no newline, extractedToolTitle keeps its default; toolContent is pushed as is.
100+
tempToolOutputArray.push(toolContent);
101+
}
102+
103+
if (toolSegment.length > 1) {
104+
currentContent += toolSegment[1];
105+
}
106+
}
107+
extractedToolOutput = tempToolOutputArray.join('\n\n');
108+
}
109+
110+
return {
111+
content: currentContent.trim(),
112+
thought: extractedThought,
113+
isThinking: isCurrentlyThinking,
114+
toolOutput: extractedToolOutput,
115+
toolTitle: extractedToolTitle,
116+
};
117+
}, [msg]);
77118
if (!viewingChat) return null;
78119

79120
return (
@@ -192,6 +233,24 @@ export default function ChatMessage({
192233
content={content}
193234
isGenerating={isPending}
194235
/>
236+
237+
{toolOutput && (
238+
<details
239+
className="collapse bg-base-200 collapse-arrow mb-4"
240+
open={true} // todo: make this configurable like showThoughtInProgress
241+
>
242+
<summary className="collapse-title">
243+
<b>{toolTitle || 'Tool Output'}</b>
244+
</summary>
245+
<div className="collapse-content">
246+
<MarkdownDisplay
247+
content={toolOutput}
248+
// Tool output is not "generating" in the same way
249+
isGenerating={false}
250+
/>
251+
</div>
252+
</details>
253+
)}
195254
</div>
196255
</>
197256
)}

tools/server/webui/src/utils/app.context.tsx

Lines changed: 31 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
} from './misc';
1717
import { BASE_URL, CONFIG_DEFAULT, isDev } from '../Config';
1818
import { matchPath, useLocation, useNavigate } from 'react-router';
19-
import { JS_TOOL_CALL_SPEC } from './js_tool_call';
19+
import { AVAILABLE_TOOLS } from './tool_calling/available_tools';
2020

2121
interface AppContextValue {
2222
// conversations and messages
@@ -211,7 +211,11 @@ export const AppContextProvider = ({
211211
dry_penalty_last_n: config.dry_penalty_last_n,
212212
max_tokens: config.max_tokens,
213213
timings_per_token: !!config.showTokensPerSecond,
214-
tools: config.jsInterpreterToolUse ? [JS_TOOL_CALL_SPEC] : undefined,
214+
tools: config.jsInterpreterToolUse
215+
? Array.from(AVAILABLE_TOOLS, ([_name, tool], _index) => tool).filter(
216+
(tool) => tool.enabled()
217+
)
218+
: undefined,
215219
...(config.custom.length ? JSON.parse(config.custom) : {}),
216220
};
217221

@@ -278,45 +282,45 @@ export const AppContextProvider = ({
278282
messageFromAPI.tool_calls &&
279283
messageFromAPI.tool_calls.length > 0
280284
) {
281-
console.log(messageFromAPI.tool_calls[0]);
285+
console.log(messageFromAPI.tool_calls);
282286
for (let i = 0; i < messageFromAPI.tool_calls.length; i++) {
283287
console.log('Tool use #' + i);
284288
const tc = messageFromAPI.tool_calls[i] as ToolCall;
285289
console.log(tc);
286290

287291
if (tc) {
288-
if (
289-
tc.function.name === 'javascript_interpreter' &&
290-
config.jsInterpreterToolUse
291-
) {
292-
// Execute code provided
293-
const args = JSON.parse(tc.function.arguments);
294-
console.log('Arguments for tool call:');
295-
console.log(args);
296-
const result = eval(args.code);
297-
console.log(result);
298-
299-
newContent += `<tool_result>${result}</tool_result>`;
292+
// Set up call id
293+
tc.call_id ??= `call_${i}`;
294+
295+
// Process tool call
296+
const toolResult = AVAILABLE_TOOLS.get(
297+
tc.function.name
298+
)?.processCall(tc);
299+
300+
if (toolResult) {
301+
newContent += `<tool>Tool use: ${tc.function.name}\n\n`;
302+
newContent += toolResult.output;
303+
newContent += '</tool>';
304+
} else {
305+
newContent += `<tool>Tool use: ${tc.function.name}\n\nError: invalid tool call!\n</tool>`;
300306
}
301307
}
302308
}
303309

304-
const toolCallsInfo = messageFromAPI.tool_calls
310+
/*const toolCallsInfo = messageFromAPI.tool_calls
305311
.map(
306312
(
307313
tc: any // Use 'any' for tc temporarily if type is not imported/defined here
308314
) =>
309-
`Tool Call Invoked: ${tc.function.name}\nArguments: ${tc.function.arguments}`
315+
`Tool invoked: ${tc.function.name}\nArguments: ${tc.function.arguments}`
310316
)
311317
.join('\n\n');
312318
313319
if (newContent.length > 0) {
314320
newContent += `\n\n${toolCallsInfo}`;
315321
} else {
316322
newContent = toolCallsInfo;
317-
}
318-
// TODO: Ideally, store structured tool_calls in pendingMsg if its type supports it.
319-
// pendingMsg.tool_calls = messageFromAPI.tool_calls;
323+
}*/
320324
}
321325

322326
pendingMsg = {
@@ -338,6 +342,13 @@ export const AppContextProvider = ({
338342
}
339343
setPending(convId, pendingMsg);
340344
onChunk(); // Update UI to show the processed message
345+
346+
// if message ended due to "finish_reason": "tool_calls"
347+
// resend it to assistant to process the result.
348+
if (choice.finish_reason === 'tool_calls') {
349+
console.log('Ended due to tool call. Interpreting results ...');
350+
generateMessage(convId, pendingId, onChunk);
351+
}
341352
} else {
342353
console.error(
343354
'API response missing choices or message:',

tools/server/webui/src/utils/js_tool_call.ts

Lines changed: 0 additions & 18 deletions
This file was deleted.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { ToolCall, ToolCallOutput, ToolCallSpec } from '../types';
2+
3+
/**
4+
* Map of available tools for function calling.
5+
* Note that these tools are not necessarily enabled by the user.
6+
*/
7+
export const AVAILABLE_TOOLS = new Map<string, AgentTool>();
8+
9+
export abstract class AgentTool {
10+
id: string;
11+
isEnabled: () => boolean;
12+
13+
constructor(id: string, enabled: () => boolean) {
14+
this.id = id;
15+
this.isEnabled = enabled;
16+
AVAILABLE_TOOLS.set(id, this);
17+
}
18+
19+
/**
20+
* "Public" wrapper for the tool call processing logic.
21+
* @param call The tool call object from the API response.
22+
* @returns The tool call output or undefined if the tool is not enabled.
23+
*/
24+
public processCall(call: ToolCall): ToolCallOutput | undefined {
25+
if (this.enabled()) {
26+
return this._process(call);
27+
}
28+
29+
return undefined;
30+
}
31+
32+
/**
33+
* Whether calling this tool is enabled.
34+
* User can toggle the status from the settings panel.
35+
* @returns enabled status.
36+
*/
37+
public enabled(): boolean {
38+
return this.isEnabled();
39+
}
40+
41+
/**
42+
* Specifications for the tool call.
43+
* https://github.com/ggml-org/llama.cpp/blob/master/docs/function-calling.md
44+
* https://platform.openai.com/docs/guides/function-calling?api-mode=responses#defining-functions
45+
*/
46+
public abstract specs(): ToolCallSpec;
47+
48+
/**
49+
* The actual tool call processing logic.
50+
* @param call: The tool call object from the API response.
51+
*/
52+
protected abstract _process(call: ToolCall): ToolCallOutput;
53+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import StorageUtils from '../storage';
2+
import { ToolCall, ToolCallOutput, ToolCallSpec } from '../types';
3+
import { AgentTool } from './available_tools';
4+
5+
class JSReplAgentTool extends AgentTool {
6+
private static readonly id = 'javascript_interpreter';
7+
private fakeLogger: FakeConsoleLog;
8+
9+
constructor() {
10+
super(
11+
JSReplAgentTool.id,
12+
() => StorageUtils.getConfig().jsInterpreterToolUse
13+
);
14+
this.fakeLogger = new FakeConsoleLog();
15+
}
16+
17+
_process(tc: ToolCall): ToolCallOutput {
18+
const args = JSON.parse(tc.function.arguments);
19+
console.log('Arguments for tool call:');
20+
console.log(args);
21+
22+
// Redirect console.log which agent will use to
23+
// the fake logger so that later we can get the content
24+
const originalConsoleLog = console.log;
25+
console.log = this.fakeLogger.log;
26+
27+
let result = '';
28+
try {
29+
// Evaluate the provided agent code
30+
result = eval(args.code);
31+
} catch (err) {
32+
result = String(err);
33+
} finally {
34+
// Ensure original console.log is restored even if eval throws
35+
console.log = originalConsoleLog;
36+
}
37+
38+
result = this.fakeLogger.content + result;
39+
40+
this.fakeLogger.clear();
41+
42+
return { call_id: tc.call_id, output: result } as ToolCallOutput;
43+
}
44+
45+
public specs(): ToolCallSpec {
46+
return {
47+
type: 'function',
48+
function: {
49+
name: this.id,
50+
description:
51+
'Executes JavaScript code in the browser console. The code should be self-contained valid javascript. You can use console.log(variable) to print out intermediate values..',
52+
parameters: {
53+
type: 'object',
54+
properties: {
55+
code: {
56+
type: 'string',
57+
description: 'Valid JavaScript code to execute.',
58+
},
59+
},
60+
required: ['code'],
61+
},
62+
},
63+
};
64+
}
65+
}
66+
export const jsAgentTool = new JSReplAgentTool();
67+
68+
class FakeConsoleLog {
69+
private _content: string = '';
70+
71+
public get content(): string {
72+
return this._content;
73+
}
74+
75+
// Use an arrow function for log to correctly bind 'this'
76+
public log = (...args: any[]): void => {
77+
// Convert arguments to strings and join them.
78+
this._content += args.map((arg) => String(arg)).join(' ') + '\n';
79+
};
80+
81+
public clear = (): void => {
82+
this._content = '';
83+
};
84+
}

0 commit comments

Comments
 (0)