mirror of
https://github.com/Jeuners/agenttwo-tools.git
synced 2026-09-09 15:02:31 +02:00
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:
parent
adb57ae6e3
commit
33125e1420
16 changed files with 747 additions and 48 deletions
41
README.md
41
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
cb.onDone();
|
||||
return;
|
||||
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();
|
||||
}
|
||||
|
|
|
|||
120
server/src/tools/calculate.ts
Normal file
120
server/src/tools/calculate.ts
Normal 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
114
server/src/tools/files.ts
Normal 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
85
server/src/tools/index.ts
Normal 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
46
server/src/tools/time.ts
Normal 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
42
server/src/tools/types.ts
Normal 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
4
web/dist/index.html
vendored
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue