Prompt history: every run records its transcript (task, each model step with tokens and cost, tool calls and results, engine follow-ups) - bounded in size, scrubbed of recognisable secrets, system prompts stored once per text. Retention in days per instance (default 14) and per agent (0 = never, -1 = forever); an hourly janitor deletes what is past it, "never" also purges. Usage analysis over run receipts: filter by period, agent, model, event, status, cost and free text; sort by every column; totals over the whole filter; hourly or daily series grouped by agent, model or event; markers for budget denials and model changes; run detail with receipt, transcript, tool calls and approvals. WebUI: Analysis > Token usage > Runs / Timeline, run detail, Prompt History with retention settings. New code, comments and texts in English; texts come from language packs (i18n/en.json, de.json) via modules/i18n.js. CLAUDE.md records English as the main language. Version 0.1.12. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -127,6 +127,15 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
|
||||
public event Action<string, string>? OnStepCompleted;
|
||||
|
||||
/// <summary>#660: Where prompt and response of every run are kept. <c>null</c> = not at all.</summary>
|
||||
public Transcripts.IRunTranscriptStore? TranscriptStore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// #660: Instance-wide retention in days (0 = never, -1 = forever). A function, so a
|
||||
/// changed setting applies to the next run without a restart.
|
||||
/// </summary>
|
||||
public Func<int> DefaultTranscriptRetention { get; init; } = () => Transcripts.PromptHistorySettings.DefaultRetentionDays;
|
||||
|
||||
/// <summary>
|
||||
/// Wird ausgelöst wenn ein neuer ChatEntry hinzugefügt wird (agentId, role, content, source).
|
||||
/// Erlaubt der UI, Nachrichten aus Hintergrund-Runs (ToolJobs, AgentComm) live anzuzeigen.
|
||||
@@ -161,9 +170,11 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var chain = delegation ?? DelegationChain.Root(agentConfig.AgentId);
|
||||
var result = await RunCoreAsync(agentConfig, userMessage, instanceId, externalCt, runId, source, chain);
|
||||
var recorder = CreateRecorder(agentConfig);
|
||||
var result = await RunCoreAsync(agentConfig, userMessage, instanceId, externalCt, runId, source, chain, recorder);
|
||||
await RecordUsageAsync(agentConfig, result);
|
||||
await RecordReceiptAsync(runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
|
||||
await SaveTranscriptAsync(recorder, runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -174,7 +185,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
CancellationToken externalCt,
|
||||
string runId,
|
||||
string? source,
|
||||
DelegationChain chain)
|
||||
DelegationChain chain,
|
||||
Transcripts.RunTranscriptRecorder recorder)
|
||||
{
|
||||
var logger = _loggerFactory.CreateLogger($"ClawdDotNet.Core.Engine.{agentConfig.AgentId}");
|
||||
var loopGuard = new LoopGuard(agentConfig.LoopGuard);
|
||||
@@ -205,6 +217,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
messages.Add(ChatMessage.System(systemPrompt));
|
||||
|
||||
messages.Add(ChatMessage.User(MitZeit(userMessage, source)));
|
||||
recorder.Begin(systemPrompt, messages[^1].Content?.ToString() ?? userMessage);
|
||||
|
||||
string? finalMessage = null;
|
||||
var truncated = false;
|
||||
@@ -229,6 +242,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
// sie bezahlt ist statt danach.
|
||||
if (await _compactor.CompactIfNeededAsync(messages, agentConfig.LoopGuard, agentConfig.Model, ct, tally.Add))
|
||||
{
|
||||
recorder.Event("Context compacted");
|
||||
OnStepCompleted?.Invoke(agentConfig.AgentId, "Context kompaktiert (RunAsync)");
|
||||
}
|
||||
|
||||
@@ -249,6 +263,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
}
|
||||
|
||||
var choice = response.Choices.FirstOrDefault();
|
||||
if (choice is not null)
|
||||
recorder.ModelStep(choice.Message, response.Usage, choice.FinishReason);
|
||||
if (choice is null)
|
||||
{
|
||||
finalMessage = "[No response from model]";
|
||||
@@ -284,6 +300,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
finally
|
||||
{
|
||||
AppendToolResponses(messages, assistantMessage.ToolCalls, toolResults);
|
||||
recorder.ToolResults(assistantMessage.ToolCalls, toolResults);
|
||||
}
|
||||
|
||||
if (abbruch is not null)
|
||||
@@ -304,6 +321,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
logger.LogWarning("Leere Antwort von {AgentId} nach {Steps} Schritten - einmal nachgefasst",
|
||||
agentConfig.AgentId, loopGuard.Steps);
|
||||
messages.Add(ChatMessage.User(LaufAbschluss.LeerNachfrage));
|
||||
recorder.EngineNote(LaufAbschluss.LeerNachfrage);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -314,6 +332,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
agentConfig.AgentId);
|
||||
messages.Add(ChatMessage.Assistant(text));
|
||||
messages.Add(ChatMessage.User(LaufAbschluss.ErgebnisNachfrage));
|
||||
recorder.EngineNote(LaufAbschluss.ErgebnisNachfrage);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -468,9 +487,11 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
|
||||
try
|
||||
{
|
||||
var result = await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source, runId, chain);
|
||||
var recorder = CreateRecorder(agentConfig);
|
||||
var result = await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source, runId, chain, recorder);
|
||||
await RecordUsageAsync(agentConfig, result);
|
||||
await RecordReceiptAsync(runId, agentConfig, result, source, taskId);
|
||||
await SaveTranscriptAsync(recorder, runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
@@ -487,7 +508,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
CancellationToken runCt,
|
||||
string? source,
|
||||
string runId,
|
||||
DelegationChain chain)
|
||||
DelegationChain chain,
|
||||
Transcripts.RunTranscriptRecorder recorder)
|
||||
{
|
||||
var logger = _loggerFactory.CreateLogger($"ClawdDotNet.Core.Engine.Chat.{agentConfig.AgentId}");
|
||||
var loopGuard = new LoopGuard(agentConfig.LoopGuard);
|
||||
@@ -532,6 +554,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
|
||||
messages.Add(ChatMessage.User(MitZeit(routedMessage, source)));
|
||||
AddChatEntry(agentConfig.AgentId, "user", userMessage, source);
|
||||
recorder.Begin(
|
||||
messages.Count > 0 && messages[0].Role == "system" ? messages[0].Content?.ToString() : null,
|
||||
messages[^1].Content?.ToString() ?? userMessage);
|
||||
|
||||
string? finalMessage = null;
|
||||
var truncated = false;
|
||||
@@ -557,6 +582,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
// nächsten Schritt. Genau ein Schritt zu spät, und der teuerste.
|
||||
if (await _compactor.CompactIfNeededAsync(messages, agentConfig.LoopGuard, agentConfig.Model, ct, tally.Add))
|
||||
{
|
||||
recorder.Event("Context compacted");
|
||||
PersistChatState(agentConfig.AgentId);
|
||||
OnStepCompleted?.Invoke(agentConfig.AgentId, "Context kompaktiert");
|
||||
}
|
||||
@@ -581,6 +607,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
}
|
||||
|
||||
var choice = response.Choices.FirstOrDefault();
|
||||
if (choice is not null)
|
||||
recorder.ModelStep(choice.Message, response.Usage, choice.FinishReason);
|
||||
if (choice is null)
|
||||
{
|
||||
finalMessage = "[No response from model]";
|
||||
@@ -614,6 +642,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
finally
|
||||
{
|
||||
AppendToolResponses(messages, assistantMessage.ToolCalls, toolResults);
|
||||
recorder.ToolResults(assistantMessage.ToolCalls, toolResults);
|
||||
}
|
||||
|
||||
if (abbruch is not null)
|
||||
@@ -636,6 +665,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
logger.LogWarning("Leere Antwort von {AgentId} nach {Steps} Schritten - einmal nachgefasst",
|
||||
agentConfig.AgentId, loopGuard.Steps);
|
||||
messages.Add(ChatMessage.User(LaufAbschluss.LeerNachfrage));
|
||||
recorder.EngineNote(LaufAbschluss.LeerNachfrage);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -647,6 +677,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
messages.Add(ChatMessage.Assistant(text));
|
||||
AddChatEntry(agentConfig.AgentId, "assistant", text, source);
|
||||
messages.Add(ChatMessage.User(LaufAbschluss.ErgebnisNachfrage));
|
||||
recorder.EngineNote(LaufAbschluss.ErgebnisNachfrage);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1746,6 +1777,37 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
/// <summary>
|
||||
/// Hält den Abschluss-Beleg eines Laufs fest (Receipt). Best effort, wie beim Audit.
|
||||
/// </summary>
|
||||
// ─── Prompt history (#660) ───
|
||||
|
||||
/// <summary>Retention that applies to this agent: its own setting or the instance default.</summary>
|
||||
public int TranscriptRetentionFor(AgentConfig agentConfig)
|
||||
=> Transcripts.PromptHistorySettings.Resolve(agentConfig.PromptHistory?.RetentionDays, DefaultTranscriptRetention());
|
||||
|
||||
private Transcripts.RunTranscriptRecorder CreateRecorder(AgentConfig agentConfig)
|
||||
=> TranscriptStore is not null && TranscriptRetentionFor(agentConfig) != Transcripts.PromptHistorySettings.Never
|
||||
? new Transcripts.RunTranscriptRecorder(enabled: true, _clock)
|
||||
: Transcripts.RunTranscriptRecorder.Disabled;
|
||||
|
||||
/// <summary>Best effort: a transcript that cannot be written must not fail the run.</summary>
|
||||
private async Task SaveTranscriptAsync(
|
||||
Transcripts.RunTranscriptRecorder recorder, string runId, AgentConfig agentConfig,
|
||||
AgentRunResult result, string source, string? taskId)
|
||||
{
|
||||
if (!recorder.Enabled || TranscriptStore is null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var transcript = recorder.Build(runId, agentConfig.AgentId, agentConfig.Model, source, taskId, result.Status.ToString());
|
||||
await TranscriptStore.SaveAsync(transcript, recorder.SystemPrompt, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.Transcripts")
|
||||
.LogWarning(ex, "Transcript of run {RunId} ({AgentId}) could not be stored", runId, agentConfig.AgentId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RecordReceiptAsync(
|
||||
string runId, AgentConfig agentConfig, AgentRunResult result, string? source, string? taskId)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user