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; } } }