Budgetsperre sichtbar machen (#608), Gedaechtnisabruf kuerzt statt wegzulassen (#607)

#608: Ein wegen erschoepften Budgets abgelehnter Lauf hinterlaesst jetzt einen Beleg
(Status BudgetExceeded) an Aufgabe und Verlauf; die Log-Warnung kommt einmal je Agent,
Grenze und Tag. Die Kostenprognose zaehlt Ablehnungen nicht als Lauf und warnt, wenn die
geplanten Aufgaben eines Agenten sein Tagesbudget uebersteigen (API + Verbrauchsansicht).

#607: Passt schon der erste Treffer nicht in den Abruf, wird er gekuerzt (Anfang und Ende
bleiben) statt ganz zu fehlen. remember weist auf gleichnamige Eintraege ohne Schluessel und
auf Eintraege nahe der Abrufgrenze hin.

Version 0.1.9.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-15 18:59:14 +02:00
co-authored by Claude Opus 5
parent b0997b5334
commit f5731c50f8
12 changed files with 408 additions and 22 deletions
+92 -12
View File
@@ -165,7 +165,47 @@ public sealed class MemoryTool : IAgentTool
return ToolResult.Ok(
$"Gemerkt (#{saved.Id}, {scopeLabel}){keyHint}\n" +
$"Betreff: {saved.Subject} | Art: {saved.Category} | Wichtigkeit: {saved.Importance}");
$"Betreff: {saved.Subject} | Art: {saved.Category} | Wichtigkeit: {saved.Importance}" +
await HinweiseAsync(saved, scope, repository, context, ct));
}
/// <summary>
/// #607: Ein Agent merkte sich bei jedem Lauf dieselbe, wachsende Liste ohne Schlüssel -
/// sieben Einträge mit zusammen 23.000 Zeichen, bis der neueste allein nicht mehr in einen
/// Abruf passte. Beides soll er erfahren, solange er es noch ändern kann.
/// </summary>
private static async Task<string> HinweiseAsync(
MemoryEntry saved, MemoryScope scope, IMemoryRepository repository, AgentToolContext context, CancellationToken ct)
{
var sb = new StringBuilder();
if (string.IsNullOrWhiteSpace(saved.Key))
{
var gleicherBetreff = await repository.RecallAsync(new MemoryQuery
{
Scope = scope,
OwnerId = context.AgentId,
Subject = saved.Subject,
Limit = 50,
MinImportance = 1
}, ct);
var ohneSchluessel = gleicherBetreff.Count(e => string.IsNullOrWhiteSpace(e.Key));
if (ohneSchluessel > 1)
{
sb.Append($"\nHinweis: Unter dem Betreff '{saved.Subject}' liegen jetzt {ohneSchluessel} Einträge " +
"ohne Schlüssel. Ersetzt dieser Eintrag einen früheren, merke ihn mit 'key' – dann wird " +
"er fortgeschrieben statt verdoppelt. Überholte Einträge entfernst du mit forget (id).");
}
}
if (saved.Content.Length > MaxResultChars / 2)
{
sb.Append($"\nHinweis: Der Eintrag ist {saved.Content.Length} Zeichen lang, ein Abruf zeigt höchstens " +
$"{MaxResultChars} Zeichen. Halte ihn kürzer oder teile ihn auf, sonst wird er beim Abruf gekürzt.");
}
return sb.ToString();
}
private static async Task<ToolResult> RecallAsync(
@@ -252,8 +292,21 @@ public sealed class MemoryTool : IAgentTool
// Lieber weniger Treffer als ein gesprengter Kontext.
if (sb.Length + block.Length > MaxResultChars)
{
sb.AppendLine($"[… {entries.Count - shown} weitere Treffer ausgelassen. " +
"Grenze die Suche ein, um sie zu sehen.]");
// #607: Passte schon der erste Treffer nicht, fiel er frueher ganz weg - der
// Agent sah keinen einzigen Inhalt und hielt alles fuer neu. Jetzt wird er
// gekuerzt; Anfang und Ende bleiben stehen, weil wachsende Listen hinten
// anhaengen und Notizen vorne beginnen.
if (shown == 0)
{
sb.Append(FormatEntry(entry, scope, MaxResultChars - sb.Length - FooterReserve));
shown++;
}
if (entries.Count > shown)
{
sb.AppendLine($"[… {entries.Count - shown} weitere Treffer ausgelassen. " +
"Grenze die Suche ein, um sie zu sehen.]");
}
break;
}
@@ -264,29 +317,56 @@ public sealed class MemoryTool : IAgentTool
return sb.ToString().TrimEnd();
}
private static string FormatEntry(MemoryEntry entry, MemoryScope scope)
/// <summary>Platz, der nach einem gekuerzten Treffer fuer den Auslassungshinweis bleibt.</summary>
private const int FooterReserve = 200;
private static string FormatEntry(MemoryEntry entry, MemoryScope scope, int? maxChars = null)
{
var sb = new StringBuilder();
sb.Append($"#{entry.Id} [{entry.Subject}] {entry.Category}, Wichtigkeit {entry.Importance}");
var header = new StringBuilder();
header.Append($"#{entry.Id} [{entry.Subject}] {entry.Category}, Wichtigkeit {entry.Importance}");
if (!string.IsNullOrWhiteSpace(entry.Key))
sb.Append($", Schlüssel: {entry.Key}");
sb.AppendLine();
sb.AppendLine($" {entry.Content}");
header.Append($", Schlüssel: {entry.Key}");
var meta = new List<string> { $"aktualisiert {entry.UpdatedAt:yyyy-MM-dd HH:mm} UTC" };
if (entry.Tags.Count > 0)
meta.Add("Schlagworte: " + string.Join(", ", entry.Tags));
if (scope == MemoryScope.Shared && !string.IsNullOrWhiteSpace(entry.CreatedBy))
meta.Add($"von {entry.CreatedBy}");
var metaLine = $" ({string.Join(" | ", meta)})";
sb.AppendLine($" ({string.Join(" | ", meta)})");
var content = entry.Content;
if (maxChars is { } max)
content = Kuerze(content, max - header.Length - metaLine.Length - 16);
var sb = new StringBuilder();
sb.Append(header).AppendLine();
sb.AppendLine($" {content}");
sb.AppendLine(metaLine);
sb.AppendLine();
return sb.ToString();
}
/// <summary>Kuerzt einen Inhalt auf etwa <paramref name="platz"/> Zeichen, Anfang und Ende bleiben.</summary>
internal static string Kuerze(string content, int platz)
{
const int Mindestens = 400;
var marke = $"\n [… gekürzt: {{0}} von {content.Length} Zeichen ausgelassen – der Eintrag ist zu groß " +
"für einen Abruf. Teile ihn auf oder halte ihn kürzer.]\n ";
if (content.Length <= platz)
return content;
var haelfte = Math.Max(Mindestens, platz - marke.Length) / 2;
if (2 * haelfte >= content.Length)
return content;
return content[..haelfte]
+ string.Format(marke, content.Length - 2 * haelfte)
+ content[^haelfte..];
}
// ─── Eingabe lesen ───
private static string? GetString(JsonElement input, string name)