- 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:
co-authored by
Claude Opus 5.5
parent
3f90e0d0cd
commit
f812dbd856
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user