118 lines
5.1 KiB
C#
118 lines
5.1 KiB
C#
|
|
using System.Diagnostics;
|
||
|
|
using System.Net.Http.Json;
|
||
|
|
using System.Text.Json;
|
||
|
|
|
||
|
|
namespace w4c_workflows.Services.Execution;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Executes a workflow <c>agent</c> step (W9) by invoking an LLM agent over HTTP
|
||
|
|
/// (D3): POSTs the step's prompt to chatapi <c>/api/agents/{id}/run</c> and returns
|
||
|
|
/// the assistant's content as the task output, so the run lifecycle continues. The
|
||
|
|
/// workflows engine stays decoupled from BotSharp — it only knows chatapi's HTTP
|
||
|
|
/// contract. The agent id is carried in the task's <c>entry.file</c> and the prompt
|
||
|
|
/// in the task's <c>input</c> (the previous task's output).
|
||
|
|
///
|
||
|
|
/// An LLM call may take tens of seconds; this executor blocks synchronously (the
|
||
|
|
/// worker awaits it), matching the engine's existing synchronous step model. If the
|
||
|
|
/// engine later needs non-blocking long-running steps, this executor is the seam to
|
||
|
|
/// hook async/callback semantics into.
|
||
|
|
/// </summary>
|
||
|
|
public sealed class AgentScriptExecutor : IScriptExecutor
|
||
|
|
{
|
||
|
|
private readonly IHttpClientFactory _httpFactory;
|
||
|
|
private readonly string _chatApiUrl;
|
||
|
|
private readonly string _chatApiToken;
|
||
|
|
private readonly int _timeoutSeconds;
|
||
|
|
private readonly ILogger<AgentScriptExecutor> _logger;
|
||
|
|
|
||
|
|
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
||
|
|
|
||
|
|
public AgentScriptExecutor(IHttpClientFactory httpFactory, IConfiguration config, ILogger<AgentScriptExecutor> logger)
|
||
|
|
{
|
||
|
|
_httpFactory = httpFactory;
|
||
|
|
_chatApiUrl = (config["Workflows:ChatApiUrl"] ?? string.Empty).TrimEnd('/');
|
||
|
|
_chatApiToken = config["Workflows:ChatApiToken"] ?? string.Empty;
|
||
|
|
_timeoutSeconds = ParseInt(config["Workflows:AgentStepTimeoutSeconds"], 120);
|
||
|
|
_logger = logger;
|
||
|
|
}
|
||
|
|
|
||
|
|
public string Language => "agent";
|
||
|
|
|
||
|
|
public bool IsAvailable() => !string.IsNullOrWhiteSpace(_chatApiUrl);
|
||
|
|
|
||
|
|
public async Task<ExecutionResult> ExecuteAsync(TaskInvocation invocation, CancellationToken ct)
|
||
|
|
{
|
||
|
|
var sw = Stopwatch.StartNew();
|
||
|
|
try
|
||
|
|
{
|
||
|
|
if (!IsAvailable())
|
||
|
|
return Fail("agent step is not configured (Workflows:ChatApiUrl is empty)", sw.Elapsed);
|
||
|
|
|
||
|
|
var agentId = invocation.EntryFile;
|
||
|
|
if (string.IsNullOrWhiteSpace(agentId))
|
||
|
|
return Fail("agent step requires entry.file to carry the agent id", sw.Elapsed);
|
||
|
|
|
||
|
|
var request = new AgentRunRequestDto { Prompt = invocation.Input ?? string.Empty };
|
||
|
|
var body = JsonSerializer.Serialize(request, Json);
|
||
|
|
|
||
|
|
using var client = _httpFactory.CreateClient();
|
||
|
|
using var httpReq = new HttpRequestMessage(HttpMethod.Post, $"{_chatApiUrl}/api/agents/{Uri.EscapeDataString(agentId)}/run")
|
||
|
|
{
|
||
|
|
Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json"),
|
||
|
|
};
|
||
|
|
httpReq.Headers.TryAddWithoutValidation("X-Tenant-Id", invocation.TenantId);
|
||
|
|
if (!string.IsNullOrWhiteSpace(_chatApiToken))
|
||
|
|
httpReq.Headers.TryAddWithoutValidation("Authorization", $"Bearer {_chatApiToken}");
|
||
|
|
|
||
|
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||
|
|
cts.CancelAfter(TimeSpan.FromSeconds(_timeoutSeconds + 15));
|
||
|
|
using var response = await client.SendAsync(httpReq, HttpCompletionOption.ResponseContentRead, cts.Token);
|
||
|
|
|
||
|
|
if (!response.IsSuccessStatusCode)
|
||
|
|
{
|
||
|
|
var errText = await response.Content.ReadAsStringAsync(ct);
|
||
|
|
return Fail($"agent '{agentId}' run failed ({(int)response.StatusCode}): {Truncate(errText)}", sw.Elapsed);
|
||
|
|
}
|
||
|
|
|
||
|
|
var result = await response.Content.ReadFromJsonAsync<AgentRunResultDto>(Json, ct)
|
||
|
|
?? new AgentRunResultDto { Content = "" };
|
||
|
|
|
||
|
|
// Return the assistant's content as task output. It must be valid, storable
|
||
|
|
// JSON, so wrap the plain text as a JSON string (same rule as local executors).
|
||
|
|
var output = JsonSerializer.Serialize(result.Content ?? string.Empty, Json);
|
||
|
|
return new ExecutionResult(true, 0, result.Content ?? string.Empty, string.Empty, output, null, sw.Elapsed);
|
||
|
|
}
|
||
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||
|
|
{
|
||
|
|
sw.Stop();
|
||
|
|
return Fail(ex.Message, sw.Elapsed);
|
||
|
|
}
|
||
|
|
finally
|
||
|
|
{
|
||
|
|
sw.Stop();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private ExecutionResult Fail(string message, TimeSpan elapsed)
|
||
|
|
{
|
||
|
|
_logger.LogWarning("Agent step execution failed: {Message}", message);
|
||
|
|
return new ExecutionResult(false, -1, string.Empty, string.Empty, null, message, elapsed);
|
||
|
|
}
|
||
|
|
|
||
|
|
private static string Truncate(string text)
|
||
|
|
=> text.Length > 400 ? text[..400] + "…" : text;
|
||
|
|
|
||
|
|
private static int ParseInt(string? text, int fallback)
|
||
|
|
=> int.TryParse(text, out var value) && value > 0 ? value : fallback;
|
||
|
|
|
||
|
|
private sealed class AgentRunRequestDto
|
||
|
|
{
|
||
|
|
public string Prompt { get; set; } = string.Empty;
|
||
|
|
}
|
||
|
|
|
||
|
|
private sealed class AgentRunResultDto
|
||
|
|
{
|
||
|
|
public string? Content { get; set; }
|
||
|
|
}
|
||
|
|
}
|