Fuenf Befunde mit einem gemeinsamen Fehlermodus: Keiner erzeugt eine Fehlermeldung. Der Lauf wird fertig, das Ergebnis sieht plausibel aus, und nur die Qualitaet ist schlechter. #547 Der Begleittext zum Werkzeugaufruf ging verloren. AssistantWithToolCalls baute eine neue Nachricht aus nur den Aufrufen; der Text, den das Modell daneben geschrieben hatte, fiel weg. Damit verlor das Modell zwischen zwei Schritten seinen eigenen Plan, und im Audit stand, dass ein Werkzeug lief, nicht warum. #548 finish_reason wurde nirgends gelesen. Eine an der Ausgabegrenze abgeschnittene Antwort galt als fertiges Ergebnis. Das trifft besonders Laeufe im Hintergrund, wo niemand zusieht. Neuer Status Truncated: Das Teilergebnis geht weiter, aber gekennzeichnet. #549 reasoning_details wurde verworfen. Bei Werkzeugnutzung muss der Denkverlauf unveraendert zurueck, sonst beginnt ein Thinking-Modell jedes Mal von vorn. Wird jetzt als rohes JSON durchgereicht - ohne Auslegung, weil Reihenfolge und Signatur daran haengen. #550 Die Anfrage verlangt jetzt einen Anbieter, der alle mitgeschickten Parameter beherrscht. Ohne das durfte ein Anbieter cache_control, reasoning oder sogar tools stillschweigend uebergehen - der Lauf lieferte dann eine gewoehnliche Textantwort, wo ein Werkzeugaufruf hingehoert haette. Dazu usage.include: der abgerechnete Betrag schlaegt die Eigenrechnung, die als Rueckfallebene bleibt. Denktiefe je Agent einstellbar, Vorgabe off. #551 Werkzeuge eines Zuges laufen gleichzeitig, sofern sie sich dafuer freigeben. Die Vorgabe von IsParallelSafe ist false, weil die falsche Antwort hier nicht auffaellt: Zwei schreibende Aufrufe auf derselben Datei scheitern nicht zuverlaessig, sondern gelegentlich. Freigegeben ist bisher nur WebFetch - es liest ausschliesslich. Die Reihenfolge der Ergebnisse folgt den Aufrufen, nicht der Fertigstellung; daran haengen die tool_call_id-Zuordnung und der Cache-Praefix. 27 neue Tests, 887 gesamt, keine Regression. Build ohne Warnungen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
317 lines
12 KiB
C#
317 lines
12 KiB
C#
using System.Net;
|
|
using System.Text.Json;
|
|
using System.Text.RegularExpressions;
|
|
using System.Xml.Linq;
|
|
using ClawdDotNet.Core.Tools;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace ClawdDotNet.Tools.WebFetch;
|
|
|
|
public sealed class WebFetchTool : IAgentTool
|
|
{
|
|
public string Name => "WebFetch";
|
|
|
|
public string Description => """
|
|
Ruft statische Webseiten oder RSS-Feeds ab und extrahiert Text + Timestamps.
|
|
Nur Domains aus der Whitelist erlaubt. Kein JavaScript-Rendering.
|
|
Aktionen: fetch, rss
|
|
""";
|
|
|
|
/// <summary>
|
|
/// Freigegeben: Dieses Werkzeug liest ausschließlich. Beide Aktionen enden in einem
|
|
/// <c>GetAsync</c>; es gibt keinen Schreibzugriff — weder auf Dateien noch auf den
|
|
/// StateStore. Drei Abrufe gleichzeitig unterscheiden sich im Ergebnis durch nichts
|
|
/// von drei Abrufen nacheinander.
|
|
///
|
|
/// <para>Zum Vergleich das Nachbarwerkzeug WebMonitor: Es sieht ähnlich aus, merkt
|
|
/// sich aber den zuletzt gesehenen Stand über <c>StateStore.SetAsync</c> und bleibt
|
|
/// deshalb seriell.</para>
|
|
/// </summary>
|
|
public bool IsParallelSafe => true;
|
|
|
|
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
|
|
{
|
|
"type": "object",
|
|
"required": ["action", "url"],
|
|
"properties": {
|
|
"action": {
|
|
"type": "string",
|
|
"enum": ["fetch", "rss"],
|
|
"description": "fetch=HTML-Seite abrufen und zu Text konvertieren, rss=RSS/Atom-Feed parsen"
|
|
},
|
|
"url": { "type": "string" },
|
|
"selector":{ "type": "string",
|
|
"description": "optional: CSS-ähnlicher Hint welcher Teil relevant ist, z.B. 'table', 'article'" }
|
|
}
|
|
}
|
|
""").RootElement.Clone();
|
|
|
|
public async Task<ToolResult> ExecuteAsync(
|
|
JsonElement input,
|
|
AgentToolContext context,
|
|
CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
var url = input.GetProperty("url").GetString()!;
|
|
var action = input.GetProperty("action").GetString()!;
|
|
|
|
IReadOnlyDictionary<string, object?>? config = context.ToolConfig.TryGetValue("WebFetch", out var c) && c is JsonElement je
|
|
? JsonSerializer.Deserialize<Dictionary<string, object?>>(je.GetRawText())
|
|
: context.ToolConfig;
|
|
|
|
if (config == null) return ToolResult.Fail("WebFetch Konfiguration fehlt.");
|
|
|
|
var allowedDomains = config.GetValueOrDefault("allowedDomains") is JsonElement ad
|
|
? ad.EnumerateArray().Select(x => x.GetString()!).ToList()
|
|
: (config.GetValueOrDefault("allowedDomains") as IEnumerable<string>)?.ToList() ?? new List<string>();
|
|
|
|
// Adresse prüfen: Schema, privates Netz, Whitelist
|
|
if (!UrlGuard.TryValidate(url, allowedDomains, out _, out var urlError))
|
|
return ToolResult.Fail(urlError!);
|
|
|
|
return action switch
|
|
{
|
|
"fetch" => await FetchPageAsync(url, allowedDomains, config, ct),
|
|
"rss" => await FetchRssAsync(url, allowedDomains, ct),
|
|
_ => ToolResult.Fail($"Unbekannte Aktion: {action}")
|
|
};
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
context.Logger.LogError(ex, "Fehler in WebFetch");
|
|
return ToolResult.Fail($"Fehler: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private async Task<ToolResult> FetchPageAsync(
|
|
string url, IReadOnlyCollection<string> allowedDomains,
|
|
IReadOnlyDictionary<string, object?> config, CancellationToken ct)
|
|
{
|
|
using var http = CreateHttpClient(config);
|
|
var fetchedAt = DateTime.UtcNow;
|
|
|
|
var (response, redirectError) = await GetFollowingRedirectsAsync(http, url, allowedDomains, ct);
|
|
if (redirectError is not null)
|
|
return redirectError;
|
|
|
|
// response ist genau dann gesetzt, wenn keine Absage kam - dem Tupel sieht der
|
|
// Compiler das nicht an. Die Pruefung sagt dasselbe nachvollziehbar und ersetzt
|
|
// das Ausrufezeichen, das hier stand.
|
|
if (response is null)
|
|
return ToolResult.Fail($"Keine Antwort von {url}.");
|
|
|
|
using var _ = response;
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
return ToolResult.Fail($"Seite nicht erreichbar: {url} → HTTP {(int)response.StatusCode} {response.ReasonPhrase}");
|
|
|
|
DateTime? dataAsOf = response.Content.Headers.LastModified?.UtcDateTime
|
|
?? response.Headers.Date?.UtcDateTime;
|
|
|
|
var html = await response.Content.ReadAsStringAsync(ct);
|
|
var maxKb = GetConfigInt(config, "maxResponseKb", 512);
|
|
if (html.Length > maxKb * 1024) html = html[..(maxKb * 1024)];
|
|
|
|
var text = StripHtml(html);
|
|
if (dataAsOf == null) dataAsOf = ExtractDateFromHtml(html);
|
|
|
|
var result = new
|
|
{
|
|
fetchedAt = fetchedAt,
|
|
dataAsOf = dataAsOf,
|
|
source = url,
|
|
data = new { text }
|
|
};
|
|
|
|
return ToolResult.Ok(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }));
|
|
}
|
|
|
|
private async Task<ToolResult> FetchRssAsync(
|
|
string url, IReadOnlyCollection<string> allowedDomains, CancellationToken ct)
|
|
{
|
|
using var http = CreateHttpClient(new Dictionary<string, object?>());
|
|
var fetchedAt = DateTime.UtcNow;
|
|
|
|
var (response, redirectError) = await GetFollowingRedirectsAsync(http, url, allowedDomains, ct);
|
|
if (redirectError is not null)
|
|
return redirectError;
|
|
|
|
// response ist genau dann gesetzt, wenn keine Absage kam - dem Tupel sieht der
|
|
// Compiler das nicht an. Die Pruefung sagt dasselbe nachvollziehbar und ersetzt
|
|
// das Ausrufezeichen, das hier stand.
|
|
if (response is null)
|
|
return ToolResult.Fail($"Keine Antwort von {url}.");
|
|
|
|
using var _ = response;
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
return ToolResult.Fail($"RSS-Feed nicht erreichbar: {url} → HTTP {(int)response.StatusCode} {response.ReasonPhrase}");
|
|
|
|
var xml = await response.Content.ReadAsStringAsync(ct);
|
|
var doc = XDocument.Parse(xml);
|
|
|
|
var items = new List<object>();
|
|
// Support RSS and Atom.
|
|
// Ohne Wurzelelement gibt es keinen Namensraum — dann gilt der leere. Vorher
|
|
// stand das Ausweichen nur an der ersten von fünf Stellen, die übrigen vier
|
|
// rechneten mit einem möglicherweise fehlenden Namensraum weiter.
|
|
var ns = doc.Root?.Name.Namespace ?? XNamespace.None;
|
|
|
|
if (doc.Root?.Name.LocalName == "rss")
|
|
{
|
|
foreach (var item in doc.Descendants("item").Take(20))
|
|
{
|
|
items.Add(new
|
|
{
|
|
title = item.Element("title")?.Value,
|
|
link = item.Element("link")?.Value,
|
|
pubDate = item.Element("pubDate")?.Value,
|
|
description = StripHtml(item.Element("description")?.Value ?? "")
|
|
});
|
|
}
|
|
}
|
|
else // Atom
|
|
{
|
|
foreach (var entry in doc.Descendants(ns + "entry").Take(20))
|
|
{
|
|
items.Add(new
|
|
{
|
|
title = entry.Element(ns + "title")?.Value,
|
|
link = entry.Element(ns + "link")?.Attribute("href")?.Value,
|
|
pubDate = entry.Element(ns + "updated")?.Value ?? entry.Element(ns + "published")?.Value,
|
|
description = StripHtml(entry.Element(ns + "summary")?.Value ?? entry.Element(ns + "content")?.Value ?? "")
|
|
});
|
|
}
|
|
}
|
|
|
|
var result = new
|
|
{
|
|
fetchedAt = fetchedAt,
|
|
dataAsOf = fetchedAt, // RSS current state
|
|
source = url,
|
|
data = new { items }
|
|
};
|
|
|
|
return ToolResult.Ok(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }));
|
|
}
|
|
|
|
private static string StripHtml(string html)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(html)) return "";
|
|
|
|
// Script und Style entfernen
|
|
html = Regex.Replace(html, "<script.*?>.*?</script>", "", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
|
html = Regex.Replace(html, "<style.*?>.*?</style>", "", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
|
|
|
// Alle anderen Tags entfernen
|
|
html = Regex.Replace(html, "<.*?>", " ", RegexOptions.Singleline);
|
|
|
|
// Entities dekodieren
|
|
html = WebUtility.HtmlDecode(html);
|
|
|
|
// Whitespace säubern
|
|
html = Regex.Replace(html, @"\s+", " ");
|
|
html = html.Replace(" \n ", "\n").Replace(" \r ", "\r").Trim();
|
|
|
|
return html;
|
|
}
|
|
|
|
private static DateTime? ExtractDateFromHtml(string html)
|
|
{
|
|
// Suche nach meta-Tags
|
|
var patterns = new[]
|
|
{
|
|
"<meta.*?property=\"article:published_time\".*?content=\"(.*?)\"",
|
|
"<meta.*?name=\"date\".*?content=\"(.*?)\"",
|
|
"<time.*?datetime=\"(.*?)\""
|
|
};
|
|
|
|
foreach (var pattern in patterns)
|
|
{
|
|
var match = Regex.Match(html, pattern, RegexOptions.IgnoreCase);
|
|
if (match.Success && DateTime.TryParse(match.Groups[1].Value, out var dt))
|
|
return dt.ToUniversalTime();
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private const int MaxRedirects = 5;
|
|
|
|
/// <summary>
|
|
/// Folgt Weiterleitungen selbst und prüft jedes Ziel erneut gegen Whitelist und
|
|
/// Netzsperren. HttpClient würde sonst ungefragt bis ins lokale Netz folgen.
|
|
/// </summary>
|
|
private static async Task<(HttpResponseMessage? Response, ToolResult? Error)> GetFollowingRedirectsAsync(
|
|
HttpClient http, string url, IReadOnlyCollection<string> allowedDomains, CancellationToken ct)
|
|
{
|
|
var current = url;
|
|
|
|
for (var hop = 0; hop <= MaxRedirects; hop++)
|
|
{
|
|
if (!UrlGuard.TryValidate(current, allowedDomains, out var uri, out var error))
|
|
{
|
|
var context = hop == 0 ? "" : $" (Weiterleitung {hop} von {url})";
|
|
return (null, ToolResult.Fail($"{error}{context}"));
|
|
}
|
|
|
|
var response = await http.GetAsync(uri, ct);
|
|
|
|
if (!IsRedirect(response.StatusCode))
|
|
return (response, null);
|
|
|
|
var location = response.Headers.Location;
|
|
response.Dispose();
|
|
|
|
if (location is null)
|
|
return (null, ToolResult.Fail($"Weiterleitung ohne Zieladresse bei {current}."));
|
|
|
|
// Relative Weiterleitungen gegen die aktuelle Adresse auflösen.
|
|
current = location.IsAbsoluteUri
|
|
? location.ToString()
|
|
: new Uri(uri!, location).ToString();
|
|
}
|
|
|
|
return (null, ToolResult.Fail(
|
|
$"Mehr als {MaxRedirects} Weiterleitungen ausgehend von {url} — abgebrochen."));
|
|
}
|
|
|
|
private static bool IsRedirect(HttpStatusCode status) => status is
|
|
HttpStatusCode.MovedPermanently or
|
|
HttpStatusCode.Found or
|
|
HttpStatusCode.SeeOther or
|
|
HttpStatusCode.TemporaryRedirect or
|
|
HttpStatusCode.PermanentRedirect;
|
|
|
|
private static HttpClient CreateHttpClient(IReadOnlyDictionary<string, object?> config)
|
|
{
|
|
// Weiterleitungen bewusst NICHT automatisch verfolgen — sie werden einzeln
|
|
// aufgelöst und geprüft.
|
|
var handler = new HttpClientHandler { AllowAutoRedirect = false };
|
|
var client = new HttpClient(handler, disposeHandler: true);
|
|
var timeout = GetConfigInt(config, "timeoutSeconds", 15);
|
|
client.Timeout = TimeSpan.FromSeconds(timeout);
|
|
client.DefaultRequestHeaders.UserAgent.ParseAdd(
|
|
config.GetValueOrDefault("userAgent")?.ToString() ?? "ClawdDotNet-Agent/1.0");
|
|
client.DefaultRequestHeaders.CacheControl = new System.Net.Http.Headers.CacheControlHeaderValue { NoCache = true, NoStore = true };
|
|
return client;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sicheres Lesen eines int-Werts aus der Config (funktioniert mit JsonElement und primitiven Typen).
|
|
/// </summary>
|
|
private static int GetConfigInt(IReadOnlyDictionary<string, object?> config, string key, int defaultValue)
|
|
{
|
|
var val = config.GetValueOrDefault(key);
|
|
return val switch
|
|
{
|
|
null => defaultValue,
|
|
int i => i,
|
|
long l => (int)l,
|
|
System.Text.Json.JsonElement je when je.ValueKind == System.Text.Json.JsonValueKind.Number => je.GetInt32(),
|
|
_ => int.TryParse(val.ToString(), out var parsed) ? parsed : defaultValue
|
|
};
|
|
}
|
|
}
|