using System.Diagnostics;
using System.Net.Http.Json;
using System.Text.Json;
namespace w4c_workflows.Services.Execution;
///
/// Executes a workflow agent step (W9) by invoking an LLM agent over HTTP
/// (D3): POSTs the step's prompt to chatapi /api/agents/{id}/run 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 entry.file and the prompt
/// in the task's input (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.
///
public sealed class AgentScriptExecutor : IScriptExecutor
{
private readonly IHttpClientFactory _httpFactory;
private readonly string _chatApiUrl;
private readonly string _chatApiToken;
private readonly int _timeoutSeconds;
private readonly ILogger _logger;
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
public AgentScriptExecutor(IHttpClientFactory httpFactory, IConfiguration config, ILogger 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 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(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; }
}
}