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

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,