Verweis statt Nacherzaehlung: die Engine stempelt den Ursprung (#697, #677)

- Werkzeugkontext kennt Lauf und Aufgabe des Laufs (RunId, RunTaskId); die
  Delegationskette traegt den Lauf des Aufrufers weiter (RunId, CallerRunId)
- Aufgaben tragen origin_run / origin_task (Datei, DB, Taskboard "Arose from"),
  gestempelt, nie vom Agenten behauptet
- Kommentare nennen den Lauf, in dem sie geschrieben wurden
- Laufbeleg: CallerRunId; Laufansicht fuehrt zu "Delegiert von" und "Delegiert an"
- #677: task_update ersetzt den Rumpf nicht mehr, Abnahmekriterien aendert nur der
  Ersteller; Kommentare der Fremdinhalt-Stufe auf fremden Aufgaben kommen gerahmt an
- WebUI: Herkunft auf der Aufgabenkarte mit Link zum Lauf

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-25 09:27:47 +02:00
co-authored by Claude Opus 5.5
parent 3f90e0d0cd
commit f812dbd856
26 changed files with 493 additions and 47 deletions
@@ -79,6 +79,13 @@ public sealed class AgentActivity
return new RunHandle(this, run);
}
/// <summary>#697: A run in progress, by its id.</summary>
public ActiveRun? Find(string runId)
{
lock (_lock)
return _active.GetValueOrDefault(runId);
}
/// <summary>The runs in progress right now.</summary>
public IReadOnlyList<ActiveRun> ActiveRuns()
{
@@ -121,6 +121,12 @@ public sealed record RunReceipt
public string ResultRef { get; init; } = "";
public DateTime OccurredAt { get; init; }
/// <summary>
/// #697: The run this one was delegated from — stamped by the engine from the delegation
/// chain. With it, a result can be followed back to the run that asked for it.
/// </summary>
public string? CallerRunId { get; init; }
}
/// <summary>
@@ -24,6 +24,9 @@ public interface IAuditRepository
/// <summary>Alle Belege zu einem Task — die Kosten pro Ergebnis (C7).</summary>
Task<IReadOnlyList<RunReceipt>> ListReceiptsForTaskAsync(string taskId, CancellationToken ct);
/// <summary>#697: The runs delegated from a run — the way down to the originals.</summary>
Task<IReadOnlyList<RunReceipt>> ListReceiptsCalledFromAsync(string runId, CancellationToken ct);
/// <summary>
/// #660: All receipts in <c>[fromUtc, toUtc)</c>, oldest first, at most
/// <paramref name="limit"/> - the input of the usage analysis.
@@ -46,11 +46,11 @@ public sealed class SqliteAuditRepository : IAuditRepository
INSERT INTO RunReceipts
(RunId, AgentId, Model, Source, TaskId, Status, StepCount,
PromptTokens, CompletionTokens, CachedTokens, CostUsd, CostIsKnown,
DurationMs, ResultRef, OccurredAt)
DurationMs, ResultRef, OccurredAt, CallerRunId)
VALUES
(@runId, @agentId, @model, @source, @taskId, @status, @stepCount,
@prompt, @completion, @cached, @cost, @costKnown,
@durationMs, @resultRef, @occurredAt)
@durationMs, @resultRef, @occurredAt, @callerRunId)
""";
cmd.Parameters.AddWithValue("@runId", receipt.RunId);
cmd.Parameters.AddWithValue("@agentId", receipt.AgentId);
@@ -67,6 +67,7 @@ public sealed class SqliteAuditRepository : IAuditRepository
cmd.Parameters.AddWithValue("@durationMs", receipt.DurationMs);
cmd.Parameters.AddWithValue("@resultRef", receipt.ResultRef);
cmd.Parameters.AddWithValue("@occurredAt", Format(receipt.OccurredAt));
cmd.Parameters.AddWithValue("@callerRunId", (object?)receipt.CallerRunId ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct);
}, ct);
@@ -117,6 +118,20 @@ public sealed class SqliteAuditRepository : IAuditRepository
return results;
}
public async Task<IReadOnlyList<RunReceipt>> ListReceiptsCalledFromAsync(string runId, CancellationToken ct)
{
await using var conn = await _storage.OpenConnectionAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT " + ReceiptColumns + " FROM RunReceipts WHERE CallerRunId = @runId ORDER BY Id";
cmd.Parameters.AddWithValue("@runId", runId);
var results = new List<RunReceipt>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
results.Add(ReadReceipt(reader));
return results;
}
public async Task<IReadOnlyList<RunReceipt>> ListReceiptsForTaskAsync(string taskId, CancellationToken ct)
{
await using var conn = await _storage.OpenConnectionAsync(ct);
@@ -249,7 +264,7 @@ public sealed class SqliteAuditRepository : IAuditRepository
private const string ReceiptColumns =
"Id, RunId, AgentId, Model, Source, TaskId, Status, StepCount, PromptTokens, " +
"CompletionTokens, CachedTokens, CostUsd, CostIsKnown, DurationMs, ResultRef, OccurredAt";
"CompletionTokens, CachedTokens, CostUsd, CostIsKnown, DurationMs, ResultRef, OccurredAt, CallerRunId";
private static async Task<IReadOnlyList<AuditEntry>> ReadEntriesAsync(SqliteCommand cmd, CancellationToken ct)
{
@@ -293,7 +308,8 @@ public sealed class SqliteAuditRepository : IAuditRepository
CostIsKnown = r.GetInt32(12) != 0,
DurationMs = r.GetInt64(13),
ResultRef = r.GetString(14),
OccurredAt = Parse(r.GetString(15))
OccurredAt = Parse(r.GetString(15)),
CallerRunId = r.IsDBNull(16) ? null : r.GetString(16)
};
private static string Format(DateTime value) => value.ToUniversalTime().ToString("O");
+16 -9
View File
@@ -203,7 +203,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
var result = await RunCoreAsync(agentConfig, userMessage, instanceId, externalCt, runId, source, chain, recorder);
activity.Complete(result.Status.ToString());
await RecordUsageAsync(agentConfig, result);
await RecordReceiptAsync(runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
await RecordReceiptAsync(runId, agentConfig, result, source ?? AuditSource.Direct, taskId, chain.CallerRunId);
await SaveTranscriptAsync(recorder, runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
return result;
}
@@ -526,7 +526,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
var result = await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source, runId, chain, recorder);
activity.Complete(result.Status.ToString());
await RecordUsageAsync(agentConfig, result);
await RecordReceiptAsync(runId, agentConfig, result, source, taskId);
await RecordReceiptAsync(runId, agentConfig, result, source, taskId, chain.CallerRunId);
await SaveTranscriptAsync(recorder, runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
return result;
}
@@ -1501,7 +1501,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
? default
: JsonDocument.Parse(arguments).RootElement;
var context = BuildToolContext(agentConfig, instanceId, toolName, ct, chain);
var context = BuildToolContext(agentConfig, instanceId, toolName, ct, chain, runId);
// #579: Was das Werkzeug ohnehin verbieten wuerde, kommt gar nicht erst in die
// Freigabeliste. Vorher lag dort z.B. ein Loeschvorschlag eines Agenten, der
@@ -1660,13 +1660,14 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
private AgentToolContext BuildToolContext(
AgentConfig agentConfig, string instanceId, string toolName, CancellationToken ct,
DelegationChain? chain = null)
DelegationChain? chain = null, string? runId = null)
{
var toolConfig = agentConfig.Tools.TryGetValue(toolName, out var cfg)
? cfg.AsReadOnly()
: new Dictionary<string, object?>().AsReadOnly();
var toolLogger = _loggerFactory.CreateLogger($"ClawdDotNet.Tools.{toolName}.Execution");
var baseChain = chain ?? DelegationChain.Root(agentConfig.AgentId);
return new AgentToolContext(
agentConfig.AgentId,
@@ -1680,10 +1681,14 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
this,
_memoryRepository,
_taskRepository,
chain ?? DelegationChain.Root(agentConfig.AgentId))
// #697: The chain carries the caller's run, so a delegated run can name where it came from.
runId is null ? baseChain : baseChain.WithRun(runId))
{
Organisation = CurrentOrganisation(),
Trust = ReportingLine.TrustOf(agentConfig, _toolRegistry.Get)
Trust = ReportingLine.TrustOf(agentConfig, _toolRegistry.Get),
// #697: Where something an agent hands on came from — known to the engine, not claimed by the agent.
RunId = runId,
RunTaskId = runId is null ? null : Activity.Find(runId)?.TaskId
};
}
@@ -1712,7 +1717,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
: JsonDocument.Parse(argumentsJson).RootElement;
// #641: Ein Mensch hat genau diesen Aufruf freigegeben.
var context = BuildToolContext(config, _instanceId, tool, ct) with { FreigabeGeprueft = true };
var context = BuildToolContext(config, _instanceId, tool, ct, runId: runId) with { FreigabeGeprueft = true };
var result = await agentTool.ExecuteAsync(input, context, ct);
var status = result.Success ? AuditStatus.Ok : AuditStatus.Error;
@@ -1907,7 +1912,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
}
private async Task RecordReceiptAsync(
string runId, AgentConfig agentConfig, AgentRunResult result, string? source, string? taskId)
string runId, AgentConfig agentConfig, AgentRunResult result, string? source, string? taskId,
string? callerRunId = null)
{
if (_auditRepository is null)
return;
@@ -1937,7 +1943,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
CostIsKnown = reported is not null || (estimate?.IsKnown ?? false),
DurationMs = (long)result.Duration.TotalMilliseconds,
ResultRef = Cap(result.FinalMessage ?? "", 500),
OccurredAt = DateTime.Now
OccurredAt = DateTime.Now,
CallerRunId = callerRunId
}, CancellationToken.None);
}
catch (Exception ex)
@@ -383,6 +383,13 @@ public sealed class SqliteStorage
// #714: The task a task serves — the goal chain.
SpalteErgaenzen(connection, "Tasks", "ParentId", "TEXT NOT NULL DEFAULT ''");
// #697: The run a delegated run came from.
SpalteErgaenzen(connection, "RunReceipts", "CallerRunId", "TEXT NULL");
// #697: Where a task came from — the run, and the task that run worked on.
SpalteErgaenzen(connection, "Tasks", "OriginRunId", "TEXT NOT NULL DEFAULT ''");
SpalteErgaenzen(connection, "Tasks", "OriginTaskId", "TEXT NOT NULL DEFAULT ''");
// Findet offene Aufrufe beim Start, ohne die ganze Tabelle zu lesen.
using var index = connection.CreateCommand();
index.CommandText = """
@@ -34,11 +34,11 @@ public sealed class SqliteTaskRepository : ITaskRepository
INSERT INTO Tasks
(Id, Title, Status, Type, Priority, Assignee, WhenKind, WhenValue, WhenTz,
RequireApproval, Acceptance, BlockedBy, OnlyWhenMarketOpen, Body, FileName,
ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt, CreatedBy, ParentId)
ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt, CreatedBy, ParentId, OriginRunId, OriginTaskId)
VALUES
(@id, @title, @status, @type, @priority, @assignee, @whenKind, @whenValue, @whenTz,
@requireApproval, @acceptance, @blockedBy, @market, @body, @fileName,
@toolName, @jobType, NULL, NULL, NULL, @createdAt, @updatedAt, @createdBy, @parentId);
@toolName, @jobType, NULL, NULL, NULL, @createdAt, @updatedAt, @createdBy, @parentId, @originRun, @originTask);
""";
BindDefinition(insert, task);
insert.Parameters.AddWithValue("@status", TaskText.Of(task.Status));
@@ -62,6 +62,8 @@ public sealed class SqliteTaskRepository : ITaskRepository
FileName = @fileName, ToolName = @toolName, JobTypeId = @jobType,
CreatedBy = CASE WHEN @createdBy = '' THEN CreatedBy ELSE @createdBy END,
ParentId = @parentId,
OriginRunId = CASE WHEN @originRun = '' THEN OriginRunId ELSE @originRun END,
OriginTaskId = CASE WHEN @originTask = '' THEN OriginTaskId ELSE @originTask END,
UpdatedAt = @updatedAt
WHERE Id = @id
""";
@@ -333,7 +335,7 @@ public sealed class SqliteTaskRepository : ITaskRepository
"Id, Title, Status, Type, Priority, Assignee, WhenKind, WhenValue, WhenTz, " +
"RequireApproval, Acceptance, BlockedBy, OnlyWhenMarketOpen, Body, FileName, " +
"ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt, "
+ "BudgetMissCount, LastBudgetMissAt, DeferredUntil, CatchUpPending, CreatedBy, ParentId";
+ "BudgetMissCount, LastBudgetMissAt, DeferredUntil, CatchUpPending, CreatedBy, ParentId, OriginRunId, OriginTaskId";
private static void BindDefinition(SqliteCommand cmd, TaskItem task)
{
@@ -356,6 +358,9 @@ public sealed class SqliteTaskRepository : ITaskRepository
// #684: a file without it (written by hand, or older) keeps what the database knows.
cmd.Parameters.AddWithValue("@createdBy", task.CreatedBy ?? "");
cmd.Parameters.AddWithValue("@parentId", task.ParentId ?? "");
// #697: Stamped once; a file written by hand, or re-imported, keeps what is known.
cmd.Parameters.AddWithValue("@originRun", task.OriginRunId ?? "");
cmd.Parameters.AddWithValue("@originTask", task.OriginTaskId ?? "");
}
private static async Task<TaskItem?> ReadAsync(SqliteConnection conn, string id, CancellationToken ct)
@@ -408,7 +413,9 @@ public sealed class SqliteTaskRepository : ITaskRepository
DeferredUntil = r.IsDBNull(24) ? null : Parse(r.GetString(24)),
CatchUpPending = r.GetInt32(25) != 0,
CreatedBy = r.GetString(26),
ParentId = r.GetString(27)
ParentId = r.GetString(27),
OriginRunId = r.GetString(28),
OriginTaskId = r.GetString(29)
};
}
@@ -154,6 +154,8 @@ public static class TaskFrontmatter
JobTypeId = scalars.GetValueOrDefault("job_type", ""),
CreatedBy = scalars.GetValueOrDefault("created_by", ""),
ParentId = scalars.GetValueOrDefault("parent", ""),
OriginRunId = scalars.GetValueOrDefault("origin_run", ""),
OriginTaskId = scalars.GetValueOrDefault("origin_task", ""),
Body = body
};
return true;
@@ -175,6 +177,10 @@ public static class TaskFrontmatter
sb.Append($"created_by: {Scalar(task.CreatedBy)}\n");
if (!string.IsNullOrWhiteSpace(task.ParentId))
sb.Append($"parent: {Scalar(task.ParentId)}\n");
if (!string.IsNullOrWhiteSpace(task.OriginRunId))
sb.Append($"origin_run: {Scalar(task.OriginRunId)}\n");
if (!string.IsNullOrWhiteSpace(task.OriginTaskId))
sb.Append($"origin_task: {Scalar(task.OriginTaskId)}\n");
if (task.When is { } w)
{
+10
View File
@@ -242,6 +242,16 @@ public sealed record TaskItem
/// </summary>
public string ParentId { get; init; } = "";
/// <summary>
/// #697: The run in which an agent created this task — stamped by the engine, never taken
/// from the agent. With it, whoever decides can go back to the original without asking:
/// what the agent saw and did when it handed this out. Empty for tasks from a human.
/// </summary>
public string OriginRunId { get; init; } = "";
/// <summary>#697: The task that run was working on, if any — the task this one arose from.</summary>
public string OriginTaskId { get; init; } = "";
/// <summary>A one-time task that was refused for lack of budget and carried over.</summary>
public bool IsCarriedOver => !IsRecurring && BudgetMissCount > 0;
@@ -99,10 +99,16 @@ public sealed class TaskboardService
}
/// <summary>Hängt einen Kommentar (Ergebnis, Kritik) an den Rumpf an.</summary>
public Task<TaskItem?> AddCommentAsync(string id, string author, string text, CancellationToken ct)
/// <param name="runId">
/// #697: The run the comment was written in — stamped by the engine, so a reader can go
/// back to what the author saw instead of relying on the retelling.
/// </param>
public Task<TaskItem?> AddCommentAsync(string id, string author, string text, CancellationToken ct, string? runId = null)
=> UpdateAsync(id, current =>
{
var stamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm");
if (!string.IsNullOrWhiteSpace(runId))
stamp += $", run {runId}";
var block = $"---\n**{author}** ({stamp}):\n\n{text.Trim()}";
var body = string.IsNullOrWhiteSpace(current.Body) ? block : $"{current.Body.TrimEnd()}\n\n{block}";
return current with { Body = body };
@@ -46,6 +46,12 @@ public sealed record AgentToolContext(
/// #715: The calling agent's trust tier, from its tools. Tools that let an agent write to
/// someone above it check the way up here. Standard in tests and outside the engine.
/// </summary>
/// <summary>#697: The run this call belongs to — for stamping where something came from.</summary>
public string? RunId { get; init; }
/// <summary>#697: The task that run works on, if it was started for one.</summary>
public string? RunTaskId { get; init; }
public global::ClawdDotNet.Core.Organisation.AgentTrust Trust { get; init; }
= global::ClawdDotNet.Core.Organisation.AgentTrust.Standard;
}
+23 -2
View File
@@ -29,7 +29,28 @@ public sealed class DelegationChain
{
private readonly string[] _agents;
private DelegationChain(string[] agents) => _agents = agents;
private DelegationChain(string[] agents, string? runId = null, string? callerRunId = null)
{
_agents = agents;
RunId = runId;
CallerRunId = callerRunId;
}
/// <summary>
/// #697: The run of the last agent in the chain — the one that is handing on. Stamped by
/// the engine when it builds a tool's context, so a delegated run can name the run it
/// came from. <c>null</c> outside a run.
/// </summary>
public string? RunId { get; }
/// <summary>
/// #697: The run of the agent before the last — the run a delegated run was started from.
/// Carried over by <see cref="Append"/>, so the new run can record its origin.
/// </summary>
public string? CallerRunId { get; }
/// <summary>#697: The same chain, stamped with the run of its last agent.</summary>
public DelegationChain WithRun(string runId) => new(_agents, runId, CallerRunId);
/// <summary>Der Anfang: ein Lauf, den niemand delegiert hat.</summary>
public static DelegationChain Root(string agentId) => new([agentId]);
@@ -49,7 +70,7 @@ public sealed class DelegationChain
/// <summary>Die Kette mit einem weiteren Agenten am Ende.</summary>
public DelegationChain Append(string agentId)
=> new([.. _agents, agentId]);
=> new([.. _agents, agentId], runId: null, callerRunId: RunId); // the new agent's run is not known yet
/// <summary>Für Meldungen und Protokoll: <c>A -> B -> C</c>.</summary>
public override string ToString() => string.Join(" -> ", _agents);