Fremde Laufzeiten: Claude Code und Befehl in der Sandbox, Egress-Proxy, Vorschlag statt Einsatz (#712)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-25 22:46:49 +02:00
co-authored by Claude Opus 5.5
parent 7d6c2c9495
commit 367ea387f6
40 changed files with 3428 additions and 29 deletions
+94 -4
View File
@@ -63,6 +63,13 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
/// <summary>#654: The sandbox tools run code in.</summary>
public Sandbox.ISandboxRunner? Sandbox { get; set; }
/// <summary>
/// #712: Runs agents whose runtime is not native — Claude Code, a command. Without it such
/// an agent does not run at all; it never falls back to the native engine, whose model and
/// costs are not what was configured.
/// </summary>
public Runtimes.ForeignRunner? ForeignRunner { get; set; }
private Organisation.OrgChart CurrentOrganisation()
=> OrganisationProvider?.Invoke() ?? new Organisation.OrgChart([], _agentConfigProvider?.Invoke() ?? []);
@@ -212,7 +219,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
var chain = delegation ?? DelegationChain.Root(agentConfig.AgentId);
using var activity = Activity.Begin(runId, agentConfig.AgentId, source ?? AuditSource.Direct, taskId, chain);
var recorder = CreateRecorder(agentConfig);
var result = await RunCoreAsync(agentConfig, userMessage, instanceId, externalCt, runId, source, chain, recorder);
var result = agentConfig.Runtime.IsForeign
? await RunForeignAsync(agentConfig, userMessage, externalCt, runId, source, taskId, recorder)
: await RunCoreAsync(agentConfig, userMessage, instanceId, externalCt, runId, source, chain, recorder);
activity.Complete(result.Status.ToString());
await RecordUsageAsync(agentConfig, result, pot);
await RecordReceiptAsync(runId, agentConfig, result, source ?? AuditSource.Direct, taskId, chain.CallerRunId, pot);
@@ -475,6 +484,73 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
}
}
/// <summary>
/// #712: One run in a foreign runtime. The briefing is the one a native run gets — rules,
/// organisation — and the message carries time and source the same way. Budget, receipt,
/// usage and transcript are handled by the caller as for any run.
/// </summary>
private async Task<AgentRunResult> RunForeignAsync(
AgentConfig agentConfig, string userMessage, CancellationToken ct, string runId, string? source, string? taskId,
Transcripts.RunTranscriptRecorder recorder)
{
var logger = _loggerFactory.CreateLogger($"ClawdDotNet.Core.Engine.{agentConfig.AgentId}");
var sw = Stopwatch.StartNew();
var briefing = SystemPromptFor(agentConfig, hasTools: true);
var message = MitZeit(userMessage, source);
recorder.Begin(briefing, message, userMessage);
Runtimes.ForeignRunOutcome outcome;
Exception? error = null;
if (ForeignRunner is null)
{
outcome = new Runtimes.ForeignRunOutcome
{
Status = AgentRunStatus.Failed,
Text = $"[Not run: the runtime '{agentConfig.Runtime.Kind}' is not set up in this instance]"
};
}
else
{
try
{
outcome = await ForeignRunner.RunAsync(
new Runtimes.ForeignRunRequest(agentConfig, runId, taskId, briefing, message), recorder.Event, ct);
}
catch (OperationCanceledException)
{
outcome = new Runtimes.ForeignRunOutcome { Status = AgentRunStatus.Cancelled, Text = "[Cancelled]" };
}
catch (Exception ex)
{
logger.LogError(ex, "Foreign run failed: {AgentId}", agentConfig.AgentId);
error = ex;
outcome = new Runtimes.ForeignRunOutcome { Status = AgentRunStatus.Failed, Text = $"[Failed: {ex.Message}]" };
}
}
sw.Stop();
if (outcome.Text is { } text)
recorder.ModelStep(ChatMessage.Assistant(text), null, outcome.Status == AgentRunStatus.Completed ? "stop" : null);
logger.LogInformation("Foreign run {Status}: {AgentId} in {Runtime}, turns={Turns}, tokens={Tokens}, duration={Duration}ms",
outcome.Status, agentConfig.AgentId, agentConfig.Runtime.Kind, outcome.Turns,
outcome.PromptTokens + outcome.CompletionTokens, sw.ElapsedMilliseconds);
var result = new AgentRunResult(
agentConfig.AgentId, outcome.Status, outcome.Text, outcome.Turns,
outcome.PromptTokens + outcome.CompletionTokens, sw.Elapsed, error)
{
PromptTokens = outcome.PromptTokens,
CompletionTokens = outcome.CompletionTokens,
CachedTokens = outcome.CachedTokens,
ReportedCostUsd = outcome.CostUsd,
Runtime = Runtimes.RuntimeKinds.Normalize(agentConfig.Runtime.Kind),
OnSubscription = outcome.OnSubscription,
ActualModel = outcome.Model
};
OnRunCompleted?.Invoke(agentConfig.Model, result);
return result;
}
public async Task<AgentRunResult> ChatAsync(
AgentConfig agentConfig,
string userMessage,
@@ -535,7 +611,19 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
// #711: "working" starts past the gate — waiting for it is not working.
using var activity = Activity.Begin(runId, agentConfig.AgentId, source ?? AuditSource.Direct, taskId, chain);
var recorder = CreateRecorder(agentConfig);
var result = await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source, runId, chain, recorder);
AgentRunResult result;
if (agentConfig.Runtime.IsForeign)
{
// #712: A foreign runtime has no conversation to continue — each message is a
// run of its own. The chat shows both sides all the same.
AddChatEntry(agentConfig.AgentId, "user", userMessage, source);
result = await RunForeignAsync(agentConfig, userMessage, runCts.Token, runId, source, taskId, recorder);
AddChatEntry(agentConfig.AgentId, "assistant", result.FinalMessage ?? $"[{result.Status}]", source);
}
else
{
result = await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source, runId, chain, recorder);
}
activity.Complete(result.Status.ToString());
await RecordUsageAsync(agentConfig, result, pot);
await RecordReceiptAsync(runId, agentConfig, result, source, taskId, chain.CallerRunId, pot);
@@ -1931,7 +2019,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
await _usageRepository.RecordAsync(new RunUsage
{
AgentId = agentConfig.AgentId,
Model = agentConfig.Model,
Model = result.ActualModel ?? agentConfig.Model,
PromptTokens = result.PromptTokens,
CompletionTokens = result.CompletionTokens,
CachedTokens = result.CachedTokens,
@@ -1941,7 +2029,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
StepCount = result.StepCount,
DurationMs = (long)result.Duration.TotalMilliseconds,
OccurredAt = DateTime.Now,
BudgetPot = budgetPot ?? ""
BudgetPot = budgetPot ?? "",
Runtime = result.Runtime,
OnSubscription = result.OnSubscription
}, CancellationToken.None);
}
catch (Exception ex)