using System.Text; using System.Text.Json; namespace MiaoJiZhang.Api.Services; public partial class OpenAiVisionClient { public async Task RunAgentAsync( string systemPrompt, string userText, IReadOnlyList tools, Func> executeTool, Func? onToken = null, Func? onToolRunning = null, CancellationToken ct = default) { Load(); if (string.IsNullOrWhiteSpace(_apiKey)) throw new InvalidOperationException("AI 功能尚未配置"); if (_protocol != "responses") throw new InvalidOperationException("AI Agent 记账要求使用 Responses 协议"); var providerTools = tools.Select(tool => new { type = "function", name = tool.Name, description = tool.Description, parameters = tool.Parameters, }).ToArray(); var input = new object[] { new { role = "system", content = systemPrompt }, new { role = "user", content = userText }, }; string? previousResponseId = null; IReadOnlyList? toolOutputs = null; var text = new StringBuilder(); var toolCallCount = 0; for (var round = 0; round < 4; round++) { object body = previousResponseId is null ? new { model = _model, input, tools = providerTools, tool_choice = "auto", max_output_tokens = _maxTokens, temperature = 0.3, thinking = new { type = "disabled" }, store = true, stream = true, } : new { model = _model, previous_response_id = previousResponseId, input = toolOutputs, tools = providerTools, tool_choice = "auto", max_output_tokens = _maxTokens, temperature = 0.3, thinking = new { type = "disabled" }, store = true, stream = true, }; var turn = await StreamAgentTurnAsync( body, async (token, tokenCt) => { text.Append(token); if (onToken is not null) await onToken(token, tokenCt); }, ct); previousResponseId = turn.ResponseId; if (turn.Calls.Count == 0) return new AgentRunResponse(text.ToString().Trim(), toolCallCount); if (string.IsNullOrWhiteSpace(previousResponseId)) throw new InvalidOperationException("AI 工具调用缺少响应 ID"); var outputs = new List(); foreach (var call in turn.Calls) { toolCallCount++; if (toolCallCount > 12) throw new InvalidOperationException("AI 工具调用次数过多,请重新描述需求"); if (onToolRunning is not null) await onToolRunning(call.Name, ct); string output; try { output = await executeTool(call, ct); } catch (Exception ex) { _logger.LogWarning( ex, "Agent tool failed. tool={Tool} callId={CallId}", call.Name, call.CallId); output = JsonSerializer.Serialize(new { ok = false, error = ex.Message, }); } outputs.Add(new { type = "function_call_output", call_id = call.CallId, output, }); } toolOutputs = outputs; } throw new InvalidOperationException("AI 工具调用轮次过多,请重新描述需求"); } private async Task StreamAgentTurnAsync( object body, Func onToken, CancellationToken ct) { var request = new HttpRequestMessage( HttpMethod.Post, _baseUrl + "/responses") { Content = new StringContent( JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"), }; request.Headers.Add("Authorization", "Bearer " + _apiKey); using var response = await _http.SendAsync( request, HttpCompletionOption.ResponseHeadersRead, ct); if (!response.IsSuccessStatusCode) { var detail = await response.Content.ReadAsStringAsync(ct); throw new HttpRequestException( $"AI Agent 请求失败 ({(int)response.StatusCode}):{ReadAgentError(detail)}"); } using var stream = await response.Content.ReadAsStreamAsync(ct); using var reader = new StreamReader(stream); var calls = new Dictionary(); string? responseId = null; var emittedText = false; while (!reader.EndOfStream && !ct.IsCancellationRequested) { var line = await reader.ReadLineAsync(ct); if (string.IsNullOrWhiteSpace(line) || line == "data: [DONE]") continue; if (!line.StartsWith("data:", StringComparison.Ordinal)) continue; var payload = line[5..].TrimStart(); if (payload.Length == 0 || payload == "[DONE]") continue; using var document = JsonDocument.Parse(payload); var root = document.RootElement; var eventType = TryGetProperty(root, "type", out var typeNode) ? typeNode.GetString() : null; if (eventType is "response.failed" or "error") throw new InvalidOperationException(ReadAgentError(payload)); if (TryGetProperty(root, "response", out var responseNode) && responseNode.ValueKind == JsonValueKind.Object && TryGetProperty(responseNode, "id", out var responseIdNode)) { responseId = responseIdNode.GetString() ?? responseId; } if (eventType == "response.output_text.delta" && TryGetProperty(root, "delta", out var deltaNode) && deltaNode.ValueKind == JsonValueKind.String) { var token = deltaNode.GetString(); if (!string.IsNullOrEmpty(token)) { emittedText = true; await onToken(token, ct); } continue; } if (eventType == "response.output_item.added" && TryGetProperty(root, "item", out var itemNode)) { AddFunctionCall(itemNode, calls); continue; } if (eventType == "response.function_call_arguments.delta") { var key = ReadCallKey(root); if (key is not null && calls.TryGetValue(key, out var builder) && TryGetProperty(root, "delta", out var argumentDelta)) { builder.Arguments.Append(argumentDelta.GetString()); } continue; } if (eventType == "response.function_call_arguments.done") { var key = ReadCallKey(root); if (key is not null && calls.TryGetValue(key, out var builder) && TryGetProperty(root, "arguments", out var argumentsNode)) { builder.Arguments.Clear(); builder.Arguments.Append(argumentsNode.GetString()); } continue; } if (eventType == "response.completed" && TryGetProperty(root, "response", out var completedResponse)) { AddFunctionCallsFromResponse(completedResponse, calls); if (!emittedText) { var completeText = ExtractResponseText(completedResponse); if (!string.IsNullOrWhiteSpace(completeText)) { emittedText = true; await onToken(completeText, ct); } } } } return new AgentProviderTurn( responseId, calls.Values .Where(call => !string.IsNullOrWhiteSpace(call.CallId) && !string.IsNullOrWhiteSpace(call.Name)) .Select(call => new AgentToolCall( call.CallId, call.Name, call.Arguments.ToString())) .ToList()); } private static void AddFunctionCallsFromResponse( JsonElement response, Dictionary calls) { if (!TryGetProperty(response, "output", out var output) || output.ValueKind != JsonValueKind.Array) return; foreach (var item in output.EnumerateArray()) AddFunctionCall(item, calls); } private static void AddFunctionCall( JsonElement item, Dictionary calls) { if (!TryGetProperty(item, "type", out var type) || type.GetString() != "function_call") return; var itemId = TryGetProperty(item, "id", out var idNode) ? idNode.GetString() : null; var callId = TryGetProperty(item, "call_id", out var callIdNode) ? callIdNode.GetString() : null; var name = TryGetProperty(item, "name", out var nameNode) ? nameNode.GetString() : null; var key = itemId ?? callId; if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(callId)) return; if (!calls.TryGetValue(key, out var builder)) { builder = new AgentCallBuilder(callId!, name ?? ""); calls[key] = builder; } if (!string.IsNullOrWhiteSpace(name)) builder.Name = name!; if (TryGetProperty(item, "arguments", out var arguments) && arguments.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(arguments.GetString())) { builder.Arguments.Clear(); builder.Arguments.Append(arguments.GetString()); } } private static string? ReadCallKey(JsonElement root) { if (TryGetProperty(root, "item_id", out var itemId)) return itemId.GetString(); if (TryGetProperty(root, "call_id", out var callId)) return callId.GetString(); return null; } private static string ReadAgentError(string raw) { try { using var document = JsonDocument.Parse(raw); var root = document.RootElement; if (TryGetProperty(root, "error", out var error)) { if (error.ValueKind == JsonValueKind.String) return error.GetString() ?? "AI 服务返回错误"; if (TryGetProperty(error, "message", out var message)) return message.GetString() ?? error.ToString(); return error.ToString(); } if (TryGetProperty(root, "message", out var directMessage)) return directMessage.GetString() ?? "AI 服务返回错误"; } catch (JsonException) { } return raw.Length > 300 ? raw[..300] : raw; } private sealed record AgentProviderTurn( string? ResponseId, IReadOnlyList Calls); private sealed class AgentCallBuilder(string callId, string name) { public string CallId { get; } = callId; public string Name { get; set; } = name; public StringBuilder Arguments { get; } = new(); } }