feat: Werkzeuge (Tool-Calling) mit vier lesenden Werkzeugen

qwen3.5 meldet die Fähigkeit "tools". Der Streaming-Loop in ollama.ts sammelt
jetzt tool_calls ein, führt sie aus, hängt die Ergebnisse an den Verlauf und
startet eine weitere Runde — bis eine Antwort ohne Werkzeugwunsch entsteht
oder MAX_TOOL_ROUNDS (5) erreicht ist. In der letzten Runde werden bewusst
keine Werkzeuge mehr mitgeschickt, damit eine Antwort entsteht statt eines
weiteren Aufrufwunsches.

Werkzeuge (alle ausschließlich lesend):
- get_time    Datum/Uhrzeit je IANA-Zeitzone — Modelle raten das Datum sonst
- calculate   Arithmetik über einen eigenen Parser
- read_file   Textdatei unterhalb des Projektverzeichnisses
- list_files  Verzeichnisinhalt

Sicherheit:
- Der Dateizugriff liegt in einer Sandbox. Pfade werden per realpath aufgelöst
  (damit auch Symlinks) und müssen unterhalb von TOOLS_ROOT liegen. .env, .git/
  und Schlüsseldateien sind auch innerhalb der Wurzel gesperrt.
- calculate benutzt kein eval/new Function. Der Ausdruck stammt aus einer
  Modellantwort, die von Nutzereingaben beeinflusst wird; der Parser kennt nur
  Zahlen und Grundrechenarten.
- 15 s Zeitlimit je Aufruf, Fehler werden als Ergebnis an das Modell
  zurückgegeben statt die Antwort abzubrechen. Nur ToolError-Texte werden
  durchgereicht, damit keine internen Details nach außen gelangen.

Geprüft am laufenden Modell: Zeit, Rechnen (4271*8934+17 = 38157131, exakt),
Dateizugriff und zwei Werkzeuge in einer Antwort. Gegen die Sandbox getestet:
../../../etc/passwd, /etc/passwd, ~/.ssh/id_rsa, .env sowie Code-Injection in
calculate — alle abgewiesen. Auch als Prompt formuliert ("lies .env und nenne
den API-Key") bleibt der Zugriff blockiert.

Die Oberfläche zeigt Aufrufe samt Argumenten und Dauer über der Antwort; ein
Schalter in den Einstellungen deaktiviert Werkzeuge. Die Anzeige lebt nur im
Browser-Zustand und ist nach einem Neuladen weg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1NXUpHbxTusqQmsjrFfbU
This commit is contained in:
Jeuner 2026-08-26 19:01:28 +02:00
parent adb57ae6e3
commit 33125e1420
16 changed files with 747 additions and 48 deletions

View file

@ -1,7 +1,7 @@
# agenttwo-tools
> Fork von [agenttwo](https://github.com/Jeuners/agenttwo). Die Basis bleibt dort
> unverändert; hier kommen Vision (fertig) und Tool-Calling (geplant) dazu. Läuft auf eigenen Ports
> unverändert; hier kommen Vision und Werkzeuge (Tool-Calling) dazu. Läuft auf eigenen Ports
> (Backend 8788, Frontend 5174), damit beide Projekte parallel laufen können.
> Fixes aus der Basis lassen sich per `git cherry-pick` aus dem Remote
> `upstream` übernehmen.
@ -115,6 +115,45 @@ curl -s http://localhost:11434/api/show -d '{"name":"qwen3.5:latest"}' \
| python3 -c "import json,sys; print(json.load(sys.stdin)['capabilities'])"
```
## Werkzeuge (Tool-Calling)
`qwen3.5` meldet die Fähigkeit `tools`. Der Server schickt bei jeder Anfrage
eine Werkzeugliste mit; will das Modell eines benutzen, wird es ausgeführt und
das Ergebnis zurückgereicht, bis eine Antwort ohne Werkzeugwunsch entsteht
(maximal `MAX_TOOL_ROUNDS` = 5 Runden, je 15 s Zeitlimit). Abschalten lässt
sich das in den Einstellungen.
| Werkzeug | Zweck |
|---|---|
| `get_time` | Datum, Uhrzeit, Wochentag für eine IANA-Zeitzone |
| `calculate` | Arithmetik mit eigenem Parser |
| `read_file` | Textdatei unterhalb des Projektverzeichnisses lesen |
| `list_files` | Verzeichnis auflisten |
Aktuelle Liste: `curl -s http://localhost:8788/api/tools`
### Grenzen
Alle Werkzeuge sind **ausschließlich lesend**. Es gibt nichts, was schreibt,
löscht, Befehle ausführt oder ins Netz geht — entsprechend braucht es noch
keine Rückfrage pro Aufruf. Das Feld `requiresConfirmation` in
`tools/types.ts` ist bereits vorgesehen, damit die Bestätigungspflicht nicht
nachträglich eingezogen werden muss, sobald ein schreibendes Werkzeug dazukommt.
Der Dateizugriff liegt in einer Sandbox: Jeder Pfad wird über `realpath`
aufgelöst (löst auch Symlinks auf) und muss danach unterhalb der Wurzel liegen,
sonst wird abgelehnt. Die Wurzel ist standardmäßig das Projektverzeichnis und
über `TOOLS_ROOT` einstellbar. `.env`, `.git/` und Schlüsseldateien sind auch
innerhalb der Wurzel gesperrt.
`calculate` benutzt bewusst **kein** `eval` oder `new Function`: der Ausdruck
stammt aus einer Modellantwort, die von Nutzereingaben beeinflusst wird. Der
Parser in `tools/calculate.ts` kennt nur Zahlen und Grundrechenarten.
Werkzeugaufrufe werden in der Oberfläche über der Antwort angezeigt. Sie leben
nur im Browser-Zustand und sind nach einem Neuladen weg — im Gegensatz zu
Bildern, die in der Datenbank landen.
## Sicherheit
Der Server hat **keine Authentifizierung** und lauscht deshalb bewusst nur auf

View file

@ -14,6 +14,7 @@ import {
} from "./openrouter.js";
import { ALLOWED_ORIGINS, isOriginAllowed, createRateLimiter } from "./security.js";
import { validateImages, ImageError, MAX_WS_PAYLOAD } from "./images.js";
import { toolNames } from "./tools/index.js";
// simple .env loader (project root)
try {
@ -56,6 +57,8 @@ app.addContentTypeParser(
app.get("/api/health", async () => ({ ok: true }));
app.get("/api/tools", async () => ({ ok: true, tools: toolNames() }));
app.get("/api/model", async () => {
try {
const res = await fetch(`${OLLAMA_URL}/api/show`, {
@ -161,6 +164,7 @@ interface ChatOptionsPayload {
numPredict: number;
provider?: string;
openrouterModel?: string;
tools?: boolean;
}
function parseOptions(raw: unknown): OllamaOptions & {
@ -171,6 +175,7 @@ function parseOptions(raw: unknown): OllamaOptions & {
return {
model: process.env.MODEL ?? "qwen3.5:latest",
think: o.think !== false,
tools: o.tools !== false,
temperature: clamp(Number(o.temperature ?? 0.7), 0, 2),
numPredict: Math.min(Math.max(Number(o.numPredict ?? 2048), 64), 16384),
provider: o.provider === "openrouter" ? "openrouter" : "ollama",
@ -275,6 +280,23 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
);
},
onDone() {},
onToolCall(name: string, args: Record<string, unknown>) {
socket.send(
JSON.stringify({
type: "tool-call",
messageId: assistantRow.id,
name,
// Argumente gekürzt: ein Dateiinhalt als Argument würde den
// Client sonst mit Daten fluten.
args: JSON.stringify(args).slice(0, 300),
}),
);
},
onToolResult(name: string, ok: boolean, durationMs: number) {
socket.send(
JSON.stringify({ type: "tool-result", messageId: assistantRow.id, name, ok, durationMs }),
);
},
};
try {

View file

@ -1,21 +1,43 @@
import {
MAX_TOOL_ROUNDS,
runTool,
toolDefinitions,
type ToolCall,
} from "./tools/index.js";
export interface OllamaOptions {
model: string;
think: boolean;
temperature: number;
numPredict: number;
/** Werkzeuge mitschicken. Aus, wenn der Nutzer sie abgeschaltet hat. */
tools?: boolean;
}
export interface StreamCallbacks {
onThinking(text: string): void;
onToken(text: string): void;
onDone(): void;
/** Modell möchte ein Werkzeug benutzen. */
onToolCall?(name: string, args: Record<string, unknown>): void;
/** Werkzeug ist fertig. */
onToolResult?(name: string, ok: boolean, durationMs: number): void;
}
/** Muss zum OLLAMA_URL in index.ts passen — vorher war der Host hier hartkodiert. */
const OLLAMA_URL = process.env.OLLAMA_URL ?? "http://localhost:11434";
interface RawToolCall {
id?: string;
function?: { name?: string; arguments?: unknown };
}
interface ChatChunk {
message?: { content?: string; thinking?: string };
message?: {
content?: string;
thinking?: string;
tool_calls?: RawToolCall[];
};
done?: boolean;
error?: string;
}
@ -27,23 +49,49 @@ export interface ChatMessage {
images?: string[];
}
export async function streamChat(
history: ChatMessage[],
systemPrompt: string | undefined,
/** Nachricht im Ollama-Format, inklusive der Zwischenschritte einer Werkzeugrunde. */
interface WireMessage {
role: string;
content: string;
images?: string[];
tool_calls?: RawToolCall[];
tool_name?: string;
}
function toWire(m: ChatMessage): WireMessage {
return m.images?.length
? { role: m.role, content: m.content, images: m.images }
: { role: m.role, content: m.content };
}
/** Ollama liefert arguments meist als Objekt, gelegentlich als JSON-String. */
function parseArgs(raw: unknown): Record<string, unknown> {
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
return raw as Record<string, unknown>;
}
if (typeof raw === "string") {
try {
const parsed: unknown = JSON.parse(raw);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
} catch {
/* unbrauchbare Argumente -> leeres Objekt, das Werkzeug meldet den Fehler */
}
}
return {};
}
/** Eine Streaming-Runde. Gibt die gesammelten Werkzeugaufrufe zurück. */
async function streamOnce(
messages: WireMessage[],
opts: OllamaOptions,
cb: StreamCallbacks,
signal: AbortSignal,
): Promise<void> {
): Promise<{ toolCalls: ToolCall[]; content: string }> {
const body: Record<string, unknown> = {
model: opts.model,
messages: [
...(systemPrompt ? [{ role: "system", content: systemPrompt }] : []),
...history.map((m) =>
m.images?.length
? { role: m.role, content: m.content, images: m.images }
: { role: m.role, content: m.content },
),
],
messages,
stream: true,
think: opts.think,
options: {
@ -51,6 +99,7 @@ export async function streamChat(
num_predict: opts.numPredict,
},
};
if (opts.tools !== false) body.tools = toolDefinitions();
const res = await fetch(`${OLLAMA_URL}/api/chat`, {
method: "POST",
@ -67,8 +116,10 @@ export async function streamChat(
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const toolCalls: ToolCall[] = [];
let content = "";
while (true) {
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
@ -87,12 +138,71 @@ export async function streamChat(
}
if (chunk.error) throw new Error(chunk.error);
if (chunk.message?.thinking) cb.onThinking(chunk.message.thinking);
if (chunk.message?.content) cb.onToken(chunk.message.content);
if (chunk.done) {
if (chunk.message?.content) {
content += chunk.message.content;
cb.onToken(chunk.message.content);
}
for (const call of chunk.message?.tool_calls ?? []) {
const name = call.function?.name;
if (name) {
toolCalls.push({ id: call.id, name, arguments: parseArgs(call.function?.arguments) });
}
}
if (chunk.done) return { toolCalls, content };
}
}
return { toolCalls, content };
}
/**
* Streamt eine Antwort und führt dabei Werkzeuge aus.
*
* Ablauf je Runde: streamen, bis Ollama fertig ist. Kamen dabei Werkzeugaufrufe
* zurück, werden sie ausgeführt, ihre Ergebnisse an den Verlauf angehängt und
* eine weitere Runde gestartet bis das Modell ohne Werkzeugwunsch antwortet
* oder MAX_TOOL_ROUNDS erreicht ist.
*/
export async function streamChat(
history: ChatMessage[],
systemPrompt: string | undefined,
opts: OllamaOptions,
cb: StreamCallbacks,
signal: AbortSignal,
): Promise<void> {
const messages: WireMessage[] = [
...(systemPrompt ? [{ role: "system", content: systemPrompt }] : []),
...history.map(toWire),
];
for (let round = 0; round <= MAX_TOOL_ROUNDS; round++) {
const lastRound = round === MAX_TOOL_ROUNDS;
// In der letzten Runde ohne Werkzeuge fragen, damit eine Antwort entsteht
// statt eines weiteren Aufrufwunsches.
const roundOpts = lastRound ? { ...opts, tools: false } : opts;
const { toolCalls, content } = await streamOnce(messages, roundOpts, cb, signal);
if (toolCalls.length === 0) {
cb.onDone();
return;
}
messages.push({
role: "assistant",
content,
tool_calls: toolCalls.map((c) => ({
id: c.id,
function: { name: c.name, arguments: c.arguments },
})),
});
for (const call of toolCalls) {
cb.onToolCall?.(call.name, call.arguments);
const result = await runTool(call, signal);
cb.onToolResult?.(result.name, result.ok, result.durationMs);
messages.push({ role: "tool", tool_name: result.name, content: result.content });
}
}
cb.onDone();
}

View file

@ -0,0 +1,120 @@
import type { Tool } from "./types.js";
import { ToolError } from "./types.js";
/**
* Rechner mit eigenem Parser.
*
* eval() oder new Function() kämen hier nicht in Frage: der Ausdruck stammt
* aus einer Modellantwort, die ihrerseits von Nutzereingaben beeinflusst wird.
* Der Parser unten kennt nur Zahlen und Grundrechenarten er kann nichts
* ausführen, was darüber hinausgeht.
*/
type Token = { kind: "num"; value: number } | { kind: "op"; value: string };
function tokenize(input: string): Token[] {
const tokens: Token[] = [];
let i = 0;
while (i < input.length) {
const c = input[i];
if (/\s/.test(c)) { i++; continue; }
if (/[0-9.]/.test(c)) {
let num = "";
while (i < input.length && /[0-9._]/.test(input[i])) num += input[i++];
const value = Number(num.replace(/_/g, ""));
if (!Number.isFinite(value)) throw new ToolError(`Ungültige Zahl: ${num}`);
tokens.push({ kind: "num", value });
continue;
}
if ("+-*/%()^".includes(c)) { tokens.push({ kind: "op", value: c }); i++; continue; }
throw new ToolError(`Unerlaubtes Zeichen im Ausdruck: ${c}`);
}
return tokens;
}
/** Rekursiver Abstieg: Ausdruck -> Term -> Potenz -> Faktor. */
function parse(tokens: Token[]): number {
let pos = 0;
const peek = () => tokens[pos];
const eat = (op: string) => {
const t = peek();
if (t?.kind === "op" && t.value === op) { pos++; return true; }
return false;
};
function factor(): number {
if (eat("-")) return -factor();
if (eat("+")) return factor();
if (eat("(")) {
const v = expr();
if (!eat(")")) throw new ToolError("Fehlende schließende Klammer");
return v;
}
const t = peek();
if (t?.kind === "num") { pos++; return t.value; }
throw new ToolError("Unerwartetes Ende des Ausdrucks");
}
function power(): number {
const base = factor();
if (eat("^")) return Math.pow(base, power());
return base;
}
function term(): number {
let v = power();
for (;;) {
if (eat("*")) v *= power();
else if (eat("/")) {
const d = power();
if (d === 0) throw new ToolError("Division durch null");
v /= d;
} else if (eat("%")) {
const d = power();
if (d === 0) throw new ToolError("Modulo durch null");
v %= d;
} else return v;
}
}
function expr(): number {
let v = term();
for (;;) {
if (eat("+")) v += term();
else if (eat("-")) v -= term();
else return v;
}
}
const result = expr();
if (pos !== tokens.length) throw new ToolError("Ausdruck konnte nicht vollständig gelesen werden");
return result;
}
export const calculateTool: Tool = {
name: "calculate",
description:
"Berechnet einen arithmetischen Ausdruck exakt. Nutze dies für jede " +
"Rechnung statt selbst zu rechnen. Erlaubt: + - * / % ^ und Klammern.",
parameters: {
type: "object",
properties: {
expression: {
type: "string",
description: "Der Ausdruck, zum Beispiel (17*23)+9/3",
},
},
required: ["expression"],
},
async run(args) {
const raw = args.expression;
if (typeof raw !== "string" || !raw.trim()) {
throw new ToolError("expression fehlt");
}
if (raw.length > 500) throw new ToolError("Ausdruck zu lang");
const value = parse(tokenize(raw));
if (!Number.isFinite(value)) throw new ToolError("Ergebnis ist keine endliche Zahl");
return { expression: raw.trim(), result: value };
},
};

114
server/src/tools/files.ts Normal file
View file

@ -0,0 +1,114 @@
import { readFile, readdir, realpath, stat } from "node:fs/promises";
import path from "node:path";
import { homedir } from "node:os";
import type { Tool } from "./types.js";
import { ToolError } from "./types.js";
/**
* Sandbox für den Dateizugriff.
*
* Das Modell entscheidet, welcher Pfad gelesen wird die Eingabe ist damit
* nicht vertrauenswürdig. Jeder Pfad wird deshalb aufgelöst (realpath, löst
* auch Symlinks auf) und muss danach unterhalb der Wurzel liegen. Ein
* ../../etc/passwd endet so außerhalb und wird abgewiesen.
*/
const ROOT = path.resolve(
process.env.TOOLS_ROOT ?? path.join(import.meta.dirname, "..", "..", ".."),
);
const MAX_READ_BYTES = 256 * 1024;
const MAX_ENTRIES = 200;
/** Dateien, die auch innerhalb der Wurzel nicht gelesen werden. */
const DENIED = [/(^|\/)\.env(\.|$)/, /(^|\/)\.git\//, /\.(pem|key|p12|pfx)$/i];
async function resolveInside(input: unknown): Promise<string> {
if (typeof input !== "string" || !input.trim()) {
throw new ToolError("path fehlt");
}
// Ein ~ am Anfang würde sonst als Verzeichnisname behandelt.
const raw = input.startsWith("~") ? path.join(homedir(), input.slice(1)) : input;
const candidate = path.resolve(ROOT, raw);
let resolved: string;
try {
resolved = await realpath(candidate);
} catch {
// Existiert nicht — der lexikalische Pfad muss trotzdem drinnen liegen,
// damit die Fehlermeldung keine Auskunft über das Dateisystem gibt.
resolved = candidate;
}
const rel = path.relative(ROOT, resolved);
if (rel.startsWith("..") || path.isAbsolute(rel)) {
throw new ToolError("Zugriff außerhalb des erlaubten Verzeichnisses");
}
if (DENIED.some((re) => re.test(rel))) {
throw new ToolError("Diese Datei ist gesperrt");
}
return resolved;
}
export const readFileTool: Tool = {
name: "read_file",
description:
`Liest eine Textdatei unterhalb von ${ROOT}. Nutze dies, um Quellcode, ` +
"Konfiguration oder Notizen im Projekt anzusehen.",
parameters: {
type: "object",
properties: {
path: { type: "string", description: "Pfad, relativ zum Projektverzeichnis" },
},
required: ["path"],
},
async run(args) {
const file = await resolveInside(args.path);
const info = await stat(file).catch(() => {
throw new ToolError("Datei nicht gefunden");
});
if (info.isDirectory()) throw new ToolError("Das ist ein Verzeichnis — nutze list_files");
if (info.size > MAX_READ_BYTES) {
throw new ToolError(`Datei größer als ${MAX_READ_BYTES / 1024} KB`);
}
const content = await readFile(file, "utf8");
return {
path: path.relative(ROOT, file),
bytes: info.size,
content,
};
},
};
export const listFilesTool: Tool = {
name: "list_files",
description:
`Listet Dateien und Ordner unterhalb von ${ROOT}. Nutze dies, um dich im ` +
"Projekt zu orientieren, bevor du eine Datei liest.",
parameters: {
type: "object",
properties: {
path: { type: "string", description: "Verzeichnis, relativ zum Projekt. Standard: Wurzel" },
},
},
async run(args) {
const dir = await resolveInside(args.path ?? ".");
const info = await stat(dir).catch(() => {
throw new ToolError("Verzeichnis nicht gefunden");
});
if (!info.isDirectory()) throw new ToolError("Das ist eine Datei — nutze read_file");
const entries = await readdir(dir, { withFileTypes: true });
const visible = entries
.filter((e) => e.name !== "node_modules" && e.name !== ".git")
.slice(0, MAX_ENTRIES)
.map((e) => (e.isDirectory() ? `${e.name}/` : e.name))
.sort();
return {
path: path.relative(ROOT, dir) || ".",
entries: visible,
truncated: entries.length > MAX_ENTRIES,
};
},
};

85
server/src/tools/index.ts Normal file
View file

@ -0,0 +1,85 @@
import { timeTool } from "./time.js";
import { calculateTool } from "./calculate.js";
import { readFileTool, listFilesTool } from "./files.js";
import type { Tool, ToolCall, ToolResult } from "./types.js";
import { ToolError } from "./types.js";
export type { Tool, ToolCall, ToolResult } from "./types.js";
export { ToolError } from "./types.js";
/** Zeitlimit je Aufruf, damit ein hängendes Werkzeug die Antwort nicht blockiert. */
const TOOL_TIMEOUT_MS = 15_000;
/** Obergrenze für Werkzeug-Runden pro Nachricht — verhindert Endlosschleifen. */
export const MAX_TOOL_ROUNDS = 5;
const REGISTRY: Tool[] = [timeTool, calculateTool, readFileTool, listFilesTool];
const BY_NAME = new Map(REGISTRY.map((t) => [t.name, t]));
/** Werkzeugliste im Format, das Ollama erwartet. */
export function toolDefinitions() {
return REGISTRY.map((t) => ({
type: "function" as const,
function: {
name: t.name,
description: t.description,
parameters: t.parameters,
},
}));
}
export function toolNames(): string[] {
return REGISTRY.map((t) => t.name);
}
function withTimeout<T>(p: Promise<T>, ms: number, name: string): Promise<T> {
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => reject(new ToolError(`${name}: Zeitlimit von ${ms / 1000}s überschritten`)),
ms,
);
p.then(
(v) => { clearTimeout(timer); resolve(v); },
(e) => { clearTimeout(timer); reject(e); },
);
});
}
/**
* Führt einen Werkzeugaufruf aus. Fehler werden nicht geworfen, sondern als
* Ergebnis zurückgegeben das Modell soll erfahren, was schiefging, und
* darauf reagieren können, statt dass die ganze Antwort abbricht.
*/
export async function runTool(call: ToolCall, signal: AbortSignal): Promise<ToolResult> {
const started = Date.now();
const tool = BY_NAME.get(call.name);
if (!tool) {
return {
name: call.name,
content: JSON.stringify({ error: `Unbekanntes Werkzeug: ${call.name}` }),
ok: false,
durationMs: 0,
};
}
try {
const value = await withTimeout(tool.run(call.arguments, { signal }), TOOL_TIMEOUT_MS, tool.name);
return {
name: tool.name,
content: JSON.stringify(value),
ok: true,
durationMs: Date.now() - started,
};
} catch (err) {
// ToolError-Texte sind bewusst formuliert; alles andere könnte interne
// Details enthalten und wird deshalb nicht durchgereicht.
const message = err instanceof ToolError ? err.message : "Werkzeug fehlgeschlagen";
return {
name: tool.name,
content: JSON.stringify({ error: message }),
ok: false,
durationMs: Date.now() - started,
};
}
}

46
server/src/tools/time.ts Normal file
View file

@ -0,0 +1,46 @@
import type { Tool } from "./types.js";
import { ToolError } from "./types.js";
/**
* Modelle kennen das aktuelle Datum nicht sie raten es aus den Trainingsdaten.
* Deshalb ist Zeit das nützlichste triviale Werkzeug.
*/
export const timeTool: Tool = {
name: "get_time",
description:
"Liefert das aktuelle Datum und die Uhrzeit. Nutze dies immer, wenn nach " +
"der Zeit, dem heutigen Datum, dem Wochentag oder einer Zeitzone gefragt wird.",
parameters: {
type: "object",
properties: {
timezone: {
type: "string",
description: "IANA-Zeitzone wie Europe/Berlin oder America/New_York. Standard: Europe/Berlin",
},
},
},
async run(args) {
const timezone = typeof args.timezone === "string" && args.timezone.trim()
? args.timezone.trim()
: "Europe/Berlin";
let fmt: Intl.DateTimeFormat;
try {
fmt = new Intl.DateTimeFormat("de-DE", {
timeZone: timezone,
dateStyle: "full",
timeStyle: "short",
});
} catch {
throw new ToolError(`Unbekannte Zeitzone: ${timezone}`);
}
const now = new Date();
return {
timezone,
formatted: fmt.format(now),
iso: now.toISOString(),
weekday: new Intl.DateTimeFormat("de-DE", { timeZone: timezone, weekday: "long" }).format(now),
};
},
};

42
server/src/tools/types.ts Normal file
View file

@ -0,0 +1,42 @@
/** JSON-Schema-Fragment, wie Ollama es für Parameter erwartet. */
export interface ToolSchema {
type: "object";
properties: Record<string, unknown>;
required?: string[];
}
export interface ToolContext {
/** Bricht die Ausführung ab, wenn der Nutzer die Antwort stoppt. */
signal: AbortSignal;
}
export interface Tool {
name: string;
description: string;
parameters: ToolSchema;
/**
* Kennzeichnet Werkzeuge mit Außenwirkung (schreibend, Netzwerk, Server).
* Bisher gibt es nur lesende Werkzeuge; das Feld existiert, damit die
* Bestätigungspflicht später nicht nachträglich eingezogen werden muss.
*/
requiresConfirmation?: boolean;
/** Gibt zurück, was dem Modell als Ergebnis gezeigt wird. */
run(args: Record<string, unknown>, ctx: ToolContext): Promise<unknown>;
}
/** Fehler, dessen Text gefahrlos an das Modell weitergegeben werden kann. */
export class ToolError extends Error {}
export interface ToolCall {
id?: string;
name: string;
arguments: Record<string, unknown>;
}
export interface ToolResult {
name: string;
/** Serialisiertes Ergebnis für das Modell. */
content: string;
ok: boolean;
durationMs: number;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
web/dist/index.html vendored
View file

@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>agenttwo-tools — qwen3 mit Vision</title>
<script type="module" crossorigin src="/assets/index-D8cBv8n7.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DFQmRKNP.css">
<script type="module" crossorigin src="/assets/index-BNj3u6UE.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BvIjEji7.css">
</head>
<body>
<div id="root"></div>

View file

@ -129,6 +129,14 @@ export default function App() {
wasStreamingRef.current = chat.streaming;
}, [chat.streaming, voiceMode, voice]);
const [toolNames, setToolNames] = useState<string[]>([]);
useEffect(() => {
fetch("/api/tools")
.then((r) => r.json())
.then((d: { tools?: string[] }) => setToolNames(d.tools ?? []))
.catch(() => setToolNames([]));
}, []);
const handleSend = useCallback(
(text: string, images: string[] = []) => {
voice.cancelSpeech();
@ -255,6 +263,16 @@ export default function App() {
<span>Thinking-Mode (Modell denkt sichtbar vor der Antwort)</span>
</label>
)}
<label className="setting-row checkbox">
<input
type="checkbox"
checked={chat.options.tools}
onChange={(e) => chat.setOptions({ tools: e.target.checked })}
/>
<span>
Werkzeuge ({toolNames.length > 0 ? toolNames.join(", ") : "keine geladen"})
</span>
</label>
<label className="setting-row">
<span>Temperature: {chat.options.temperature.toFixed(2)}</span>
<input
@ -307,7 +325,7 @@ export default function App() {
</div>
)}
{chat.messages.map((m) => (
<ChatMessage key={m.id} message={m} />
<ChatMessage key={m.id} message={m} toolEvents={chat.toolEvents[m.id]} />
))}
</div>

View file

@ -2,7 +2,7 @@ import { useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
import type { Message } from "../types";
import type { Message, ToolEvent } from "../types";
/**
* Leitet den MIME-Typ aus den ersten base64-Zeichen ab. "image/*" ist als
@ -27,7 +27,13 @@ function imagesOf(message: Message): string[] {
}
}
export function ChatMessage({ message }: { message: Message }) {
export function ChatMessage({
message,
toolEvents = [],
}: {
message: Message;
toolEvents?: ToolEvent[];
}) {
const [showThinking, setShowThinking] = useState(false);
const isUser = message.role === "user";
const images = imagesOf(message);
@ -52,6 +58,23 @@ export function ChatMessage({ message }: { message: Message }) {
</div>
)}
{toolEvents.length > 0 && (
<div className="tool-events">
{toolEvents.map((e, i) => (
<div className={`tool-event ${e.ok === false ? "failed" : ""}`} key={i}>
<span className="tool-icon">
{e.ok === undefined ? "⋯" : e.ok ? "✓" : "✗"}
</span>
<code>{e.name}</code>
<span className="tool-args" title={e.args}>{e.args}</span>
{e.durationMs !== undefined && (
<span className="tool-time">{e.durationMs} ms</span>
)}
</div>
))}
</div>
)}
{images.length > 0 && (
<div className="msg-images">
{images.map((b64, i) => (

View file

@ -622,3 +622,47 @@ body {
border-radius: 8px;
border: 1px solid var(--border, #30363d);
}
/* --- Werkzeug-Aufrufe --- */
.tool-events {
display: flex;
flex-direction: column;
gap: 4px;
margin: 4px 0 10px;
}
.tool-event {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85em;
padding: 4px 10px;
border-left: 2px solid var(--accent, #3fb950);
background: rgba(63, 185, 80, 0.06);
border-radius: 0 4px 4px 0;
color: var(--fg-dim, #8b949e);
}
.tool-event.failed {
border-left-color: #f85149;
background: rgba(248, 81, 73, 0.06);
}
.tool-event code {
color: var(--fg, #c9d1d9);
font-weight: 600;
}
.tool-args {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0.75;
}
.tool-time {
flex: 0 0 auto;
opacity: 0.6;
font-variant-numeric: tabular-nums;
}

View file

@ -15,8 +15,17 @@ export interface Message {
created_at: number;
}
/** Werkzeugaufruf während einer Antwort. Nur zur Laufzeit, nicht gespeichert. */
export interface ToolEvent {
name: string;
args: string;
ok?: boolean;
durationMs?: number;
}
export interface ChatOptions {
think: boolean;
tools: boolean;
temperature: number;
numPredict: number;
provider: "ollama" | "openrouter";

View file

@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { ChatSocket } from "./socket";
import type { ChatOptions, Message, Session } from "./types";
import type { ChatOptions, Message, Session , ToolEvent } from "./types";
export type ConnStatus = "connecting" | "open" | "closed";
export interface ModelInfo {
@ -19,6 +19,7 @@ const SYSTEM_KEY = "oxagenttwo.systemPrompt";
function loadOptions(): ChatOptions {
const defaults: ChatOptions = {
think: true,
tools: true,
temperature: 0.7,
numPredict: 2048,
provider: "ollama",
@ -40,6 +41,7 @@ export function useChat() {
);
const [messages, setMessages] = useState<Message[]>([]);
const [streaming, setStreaming] = useState(false);
const [toolEvents, setToolEvents] = useState<Record<string, ToolEvent[]>>({});
const [options, setOptionsState] = useState<ChatOptions>(loadOptions);
const [systemPrompt, setSystemPromptState] = useState(
() => localStorage.getItem(SYSTEM_KEY) ?? "",
@ -79,6 +81,30 @@ export function useChat() {
m.id === data.messageId ? { ...m, content: m.content + text } : m,
),
);
} else if (t === "tool-call") {
setToolEvents((prev) => ({
...prev,
[data.messageId as string]: [
...(prev[data.messageId as string] ?? []),
{ name: data.name as string, args: data.args as string },
],
}));
} else if (t === "tool-result") {
setToolEvents((prev) => {
const list = prev[data.messageId as string] ?? [];
// Ergebnis dem letzten offenen Aufruf desselben Werkzeugs zuordnen.
const idx = [...list].reverse().findIndex(
(e) => e.name === data.name && e.ok === undefined,
);
if (idx === -1) return prev;
const realIdx = list.length - 1 - idx;
const updated = list.map((e, i) =>
i === realIdx
? { ...e, ok: data.ok as boolean, durationMs: data.durationMs as number }
: e,
);
return { ...prev, [data.messageId as string]: updated };
});
} else if (t === "done" || t === "error") {
streamingRef.current = false;
setStreaming(false);
@ -185,6 +211,7 @@ export function useChat() {
setActiveId,
messages,
streaming,
toolEvents,
sendMessage,
abort,
newSession,