Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions aevatar.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@
<Project Path="src/Aevatar.Foundation.Runtime.Implementations.Orleans.Streaming/Aevatar.Foundation.Runtime.Implementations.Orleans.Streaming.csproj" />
<Project Path="src/Aevatar.Foundation.Runtime.Implementations.Orleans.Transport.KafkaProvider/Aevatar.Foundation.Runtime.Implementations.Orleans.Transport.KafkaProvider.csproj" />
<Project Path="src/Aevatar.Foundation.Runtime.Implementations.Orleans/Aevatar.Foundation.Runtime.Implementations.Orleans.csproj" />
<Project Path="src/Aevatar.Interop.A2A.Abstractions/Aevatar.Interop.A2A.Abstractions.csproj" />
<Project Path="src/Aevatar.Interop.A2A.Application/Aevatar.Interop.A2A.Application.csproj" />
<Project Path="src/Aevatar.Interop.A2A.Hosting/Aevatar.Interop.A2A.Hosting.csproj" />
<Project Path="src\Aevatar.AI.Abstractions\Aevatar.AI.Abstractions.csproj" />
<Project Path="src\Aevatar.AI.Core\Aevatar.AI.Core.csproj" />
<Project Path="src\Aevatar.AI.LLMProviders.MEAI\Aevatar.AI.LLMProviders.MEAI.csproj" />
Expand Down Expand Up @@ -121,6 +124,7 @@
<Project Path="src/workflow/extensions/Aevatar.Workflow.Extensions.Bridge/Aevatar.Workflow.Extensions.Bridge.csproj" />
</Folder>
<Folder Name="/test/">
<Project Path="test/Aevatar.Interop.A2A.Tests/Aevatar.Interop.A2A.Tests.csproj" />
<Project Path="test\Aevatar.AI.Core.Tests\Aevatar.AI.Core.Tests.csproj" />
<Project Path="test\Aevatar.AI.Tests\Aevatar.AI.Tests.csproj" />
<Project Path="test\Aevatar.AI.ToolProviders.ServiceInvoke.Tests\Aevatar.AI.ToolProviders.ServiceInvoke.Tests.csproj" />
Expand Down
8 changes: 0 additions & 8 deletions docs/decisions/0007-stream-forward.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
<<<<<<<< HEAD:docs/history/2026-03/STREAM_FORWARD_ARCHITECTURE.md
# Aevatar Stream Forward 架构说明
========
---
title: "Aevatar Stream Forward 架构说明(2026-02-22)"
status: active
owner: eanzhao
---

# Aevatar Stream Forward 架构说明(2026-02-22)
>>>>>>>> c20fc87ec173e49be645ea287f4bb54ecd975935:docs/decisions/0007-stream-forward.md

> Last updated: 2026-04-03. Active runtime paths: `InMemory` (dev/test) and `Orleans` with `KafkaProvider` (production).

Expand All @@ -24,11 +20,7 @@ owner: eanzhao
不包含内容:

1. 业务域事件建模(由 Domain/Application 文档负责)。
<<<<<<<< HEAD:docs/history/2026-03/STREAM_FORWARD_ARCHITECTURE.md
2. CQRS 读模型投影细节(由 `docs/architecture/CQRS_ARCHITECTURE.md` 负责)。
========
2. CQRS 读模型投影细节(由 `docs/canon/cqrs-projection.md` 负责)。
>>>>>>>> c20fc87ec173e49be645ea287f4bb54ecd975935:docs/decisions/0007-stream-forward.md

## 2. 设计原则

Expand Down
2 changes: 1 addition & 1 deletion global.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"sdk": {
"version": "10.0.103",
"version": "10.0.100",
"rollForward": "latestFeature"
}
}
73 changes: 38 additions & 35 deletions src/Aevatar.AI.Abstractions/LLMProviders/LLMRequest.cs
Original file line number Diff line number Diff line change
@@ -1,36 +1,39 @@
// ─────────────────────────────────────────────────────────────
// LLMRequest / ChatMessage / ToolCall — LLM 请求与消息模型
// 封装 Chat API 所需的消息列表、工具、参数等
// LLMRequest / ChatMessage / ToolCall — LLM request and message models
// Encapsulates the message list, tools, parameters, and other data required by the Chat API
// ─────────────────────────────────────────────────────────────

using Aevatar.AI.Abstractions.ToolProviders;

namespace Aevatar.AI.Abstractions.LLMProviders;

/// <summary>LLM 请求 DTO。包含消息、工具、模型参数。</summary>
/// <summary>LLM request DTO. Includes messages, tools, and model parameters.</summary>
public sealed class LLMRequest
{
/// <summary>对话消息列表,按顺序排列(system / user / assistant / tool)。</summary>
/// <summary>Conversation message list in order (system / user / assistant / tool).</summary>
public required List<ChatMessage> Messages { get; init; }

/// <summary>稳定请求标识,用于 replay/dedup/outbox 等跨边界关联。</summary>
/// <summary>Stable request identifier used for cross-boundary correlation in replay/dedup/outbox scenarios.</summary>
public string? RequestId { get; init; }

/// <summary>透传给 provider/middleware 的附加 metadata。</summary>
/// <summary>Additional metadata passed through to the provider/middleware.</summary>
public IReadOnlyDictionary<string, string>? Metadata { get; init; }

/// <summary>可选工具列表,供 LLM 选择调用。</summary>
/// <summary>Optional list of tools available for the LLM to invoke.</summary>
public IReadOnlyList<IAgentTool>? Tools { get; init; }

/// <summary>可选模型名称,覆盖 Provider 默认模型。</summary>
/// <summary>Optional model name that overrides the provider default model.</summary>
public string? Model { get; init; }

/// <summary>可选温度参数,控制生成随机性。</summary>
/// <summary>Optional temperature parameter that controls generation randomness.</summary>
public double? Temperature { get; init; }

/// <summary>可选最大生成 Token 数。</summary>
/// <summary>Optional maximum number of output tokens.</summary>
public int? MaxTokens { get; init; }

/// <summary>Optional response format constraint (Text / JsonObject / JsonSchema).</summary>
public LLMResponseFormat? ResponseFormat { get; init; }

public IReadOnlySet<ContentPartKind> GetRequestedInputModalities()
{
var modalities = new HashSet<ContentPartKind>();
Expand All @@ -55,36 +58,36 @@ public IReadOnlySet<ContentPartKind> GetRequestedInputModalities()
}
}

/// <summary>单条 Chat 消息。支持 system / user / assistant / tool 四种角色。</summary>
/// <summary>A single Chat message. Supports the system / user / assistant / tool roles.</summary>
public sealed class ChatMessage
{
/// <summary>消息角色:system / user / assistant / tool</summary>
/// <summary>Message role: system / user / assistant / tool.</summary>
public required string Role { get; init; }

/// <summary>文本内容,tool 角色时表示工具执行结果。</summary>
/// <summary>Text content; for the tool role, this represents the tool execution result.</summary>
public string? Content { get; init; }

/// <summary>多模态内容分片(文本/图片)。存在时优先由 Provider 按分片构造消息。</summary>
/// <summary>Multimodal content parts (text/image). When present, the provider should construct the message from the parts first.</summary>
public IReadOnlyList<ContentPart>? ContentParts { get; init; }

/// <summary>tool 角色时,对应 tool_call 的 Id。</summary>
/// <summary>For the tool role, the corresponding tool_call Id.</summary>
public string? ToolCallId { get; init; }

/// <summary>assistant 角色时,LLM 返回的 tool_call 列表。</summary>
/// <summary>For the assistant role, the tool_call list returned by the LLM.</summary>
public IReadOnlyList<ToolCall>? ToolCalls { get; init; }

/// <summary>创建 system 角色消息。</summary>
/// <summary>Creates a system-role message.</summary>
public static ChatMessage System(string content) => new() { Role = "system", Content = content };

/// <summary>创建 user 角色消息。</summary>
/// <summary>Creates a user-role message.</summary>
public static ChatMessage User(string content) => new() { Role = "user", Content = content };

/// <summary>创建 assistant 角色消息。</summary>
/// <summary>Creates an assistant-role message.</summary>
public static ChatMessage Assistant(string content) => new() { Role = "assistant", Content = content };

/// <summary>创建 tool 角色消息,携带工具执行结果。</summary>
/// <param name="callId">对应 tool_call 的 Id。</param>
/// <param name="result">工具执行结果 JSON 字符串。</param>
/// <summary>Creates a tool-role message carrying the tool execution result.</summary>
/// <param name="callId">The corresponding tool_call Id.</param>
/// <param name="result">Tool execution result as a JSON string.</param>
public static ChatMessage Tool(string callId, string result) => new() { Role = "tool", ToolCallId = callId, Content = result };

public static ChatMessage User(IReadOnlyList<ContentPart> parts, string? text = null) => new()
Expand All @@ -104,32 +107,32 @@ public enum ContentPartKind
Video = 4,
}

/// <summary>多模态内容分片。</summary>
/// <summary>Multimodal content part.</summary>
public sealed class ContentPart
{
/// <summary>分片类型:text / image / audio / video</summary>
/// <summary>Part kind: text / image / audio / video.</summary>
public required ContentPartKind Kind { get; init; }

/// <summary>文本分片内容。</summary>
/// <summary>Text part content.</summary>
public string? Text { get; init; }

/// <summary>媒体分片的内联 base64 数据(不带 data-uri 头)。</summary>
/// <summary>Inline base64 data for a media part (without the data-uri prefix).</summary>
public string? DataBase64 { get; init; }

/// <summary>媒体 MIME 类型(例如 image/pngaudio/wavvideo/mp4)。</summary>
/// <summary>Media MIME type (for example image/png, audio/wav, video/mp4).</summary>
public string? MediaType { get; init; }

/// <summary>媒体远程地址或 data-uri</summary>
/// <summary>Remote media URI or data-uri.</summary>
public string? Uri { get; init; }

/// <summary>可选显示名或文件名。</summary>
/// <summary>Optional display name or file name.</summary>
public string? Name { get; init; }

/// <summary>创建文本分片。</summary>
/// <summary>Creates a text part.</summary>
public static ContentPart TextPart(string text) =>
new() { Kind = ContentPartKind.Text, Text = text };

/// <summary>创建图片分片。</summary>
/// <summary>Creates an image part.</summary>
public static ContentPart ImagePart(string dataBase64, string mediaType = "image/png", string? name = null) =>
new() { Kind = ContentPartKind.Image, DataBase64 = dataBase64, MediaType = mediaType, Name = name };

Expand All @@ -149,15 +152,15 @@ public static ContentPart VideoUriPart(string uri, string mediaType = "video/mp4
new() { Kind = ContentPartKind.Video, Uri = uri, MediaType = mediaType, Name = name };
}

/// <summary>单次工具调用。包含 Id、名称、参数 JSON</summary>
/// <summary>A single tool call. Includes Id, name, and parameter JSON.</summary>
public sealed class ToolCall
{
/// <summary>工具调用唯一标识。</summary>
/// <summary>Unique tool call identifier.</summary>
public required string Id { get; init; }

/// <summary>工具名称。</summary>
/// <summary>Tool name.</summary>
public required string Name { get; init; }

/// <summary>工具参数 JSON 字符串。</summary>
/// <summary>Tool parameter JSON string.</summary>
public required string ArgumentsJson { get; init; }
}
90 changes: 90 additions & 0 deletions src/Aevatar.AI.Abstractions/LLMProviders/LLMResponseFormat.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// ─────────────────────────────────────────────────────────────
// LLMResponseFormat — structured output constraints
//
// Three modes: Text (default free text), JsonObject (JSON mode),
// and JsonSchema (strict JSON with schema constraints).
// ─────────────────────────────────────────────────────────────

using System.Text.Json;
using System.Text.RegularExpressions;
using Aevatar.AI.Abstractions.ToolProviders;

namespace Aevatar.AI.Abstractions.LLMProviders;

/// <summary>LLM response format constraints.</summary>
public class LLMResponseFormat
{
/// <summary>Free text (default).</summary>
public static LLMResponseFormat Text { get; } = new() { Kind = LLMResponseFormatKind.Text };

/// <summary>JSON mode without a schema.</summary>
public static LLMResponseFormat JsonObject { get; } = new() { Kind = LLMResponseFormatKind.JsonObject };

/// <summary>Strict JSON constrained by a JSON Schema.</summary>
public static LLMResponseFormat ForJsonSchema(
JsonElement schema,
string? schemaName = null,
string? schemaDescription = null) =>
new LLMResponseFormatJsonSchema(schema, schemaName, schemaDescription);

/// <summary>Automatically generates JSON Schema constraints from a C# type.</summary>
public static LLMResponseFormat ForJsonSchema<T>(
string? schemaName = null,
string? schemaDescription = null) =>
new LLMResponseFormatJsonSchema(
AgentToolSchemaGenerator.GenerateSchema<T>(),
schemaName ?? SanitizeTypeName(typeof(T)),
schemaDescription);

/// <summary>The format kind.</summary>
public LLMResponseFormatKind Kind { get; protected init; } = LLMResponseFormatKind.Text;

/// <summary>Sanitizes a CLR type name into a provider-safe schema name.</summary>
internal static string SanitizeTypeName(Type type)
{
var name = type.Name;
// Remove generic suffixes such as `1, `2, etc.
var idx = name.IndexOf('`');
if (idx >= 0) name = name[..idx];
// Replace non-alphanumeric characters
return Regex.Replace(name, @"[^a-zA-Z0-9_]", "_");
}
}

/// <summary>Response format kind enumeration.</summary>
public enum LLMResponseFormatKind
{
/// <summary>Free text (default).</summary>
Text = 0,

/// <summary>JSON mode without schema constraints.</summary>
JsonObject = 1,

/// <summary>Strict JSON with a JSON Schema.</summary>
JsonSchema = 2,
}

/// <summary>Strict JSON format constraints with a JSON Schema.</summary>
public sealed class LLMResponseFormatJsonSchema : LLMResponseFormat
{
public LLMResponseFormatJsonSchema(
JsonElement schema,
string? schemaName = null,
string? schemaDescription = null)
{
Kind = LLMResponseFormatKind.JsonSchema;
// Clone to decouple from caller's JsonDocument lifetime
Schema = schema.Clone();
SchemaName = schemaName;
SchemaDescription = schemaDescription;
}

/// <summary>The JSON Schema.</summary>
public JsonElement Schema { get; }

/// <summary>The schema name (required by some providers).</summary>
public string? SchemaName { get; }

/// <summary>The schema description.</summary>
public string? SchemaDescription { get; }
}
73 changes: 73 additions & 0 deletions src/Aevatar.AI.Abstractions/ToolProviders/AgentToolBase.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// ─────────────────────────────────────────────────────────────
// AgentToolBase<TParams> — type-safe base class for IAgentTool
//
// Automatically derives ParametersSchema from TParams; subclasses only need to implement
// Name / Description / ExecuteAsync(TParams, CancellationToken)。
// ─────────────────────────────────────────────────────────────

using System.Text.Json;
using System.Text.Json.Serialization;

namespace Aevatar.AI.Abstractions.ToolProviders;

/// <summary>
/// Type-safe base class for <see cref="IAgentTool"/>.
/// <see cref="ParametersSchema"/> is automatically generated from <typeparamref name="TParams"/>.
/// </summary>
/// <typeparam name="TParams">Tool parameter type used for automatic JSON Schema generation and deserialization.</typeparam>
public abstract class AgentToolBase<TParams> : IAgentTool where TParams : class
{
private static readonly string CachedSchema = AgentToolSchemaGenerator.GenerateSchemaString<TParams>();

private static readonly JsonSerializerOptions DeserializeOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};

/// <inheritdoc />
public abstract string Name { get; }

/// <inheritdoc />
public abstract string Description { get; }

/// <summary>The JSON Schema automatically generated from <typeparamref name="TParams"/>.</summary>
public string ParametersSchema => CachedSchema;

/// <inheritdoc />
public virtual ToolApprovalMode ApprovalMode => ToolApprovalMode.NeverRequire;

/// <inheritdoc />
public virtual bool IsReadOnly => false;

/// <inheritdoc />
public virtual bool IsDestructive => false;

/// <inheritdoc />
public virtual bool? RequiresApproval(string argumentsJson) => null;

/// <summary>Type-safe execution method.</summary>
protected abstract Task<string> ExecuteAsync(TParams parameters, CancellationToken ct);

/// <inheritdoc />
public Task<string> ExecuteAsync(string argumentsJson, CancellationToken ct = default)
{
TParams? parameters;
try
{
parameters = string.IsNullOrWhiteSpace(argumentsJson)
? null
: JsonSerializer.Deserialize<TParams>(argumentsJson, DeserializeOptions);
}
catch (JsonException ex)
{
return Task.FromResult(JsonSerializer.Serialize(new { error = $"Invalid parameters: {ex.Message}" }));
}

if (parameters is null)
return Task.FromResult("""{"error":"Parameters are required"}""");

return ExecuteAsync(parameters, ct);
}
}
Loading
Loading