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
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