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
Original file line number Diff line number Diff line change
Expand Up @@ -188,14 +188,15 @@ private static WorkflowCompatibilityProfile CreateAevatarV1()
"max_tool_rounds",
"max_history_messages",
"allowed_tools",
"tool_sets",
"event_modules",
"event_routes",
"connectors",
"extensions"),
AllowedRoleExtensionFields = ImmutableHashSet.Create(comparer, "event_modules", "event_routes"),
AllowedStepFields = ImmutableHashSet.Create(
comparer,
["id", "type", "target_role", "role", "allowed_tools", "capability", "parameters", "next", "branches", "children", "retry", "on_error", "timeout_ms", .. rootParameterFields]),
["id", "type", "target_role", "role", "allowed_tools", "tool_sets", "capability", "parameters", "next", "branches", "children", "retry", "on_error", "timeout_ms", .. rootParameterFields]),
AllowedRetryFields = ImmutableHashSet.Create(comparer, "max_attempts", "backoff", "delay_ms"),
AllowedOnErrorFields = ImmutableHashSet.Create(comparer, "strategy", "fallback_step", "default_output"),
AllowedBranchListFields = ImmutableHashSet.Create(comparer, "condition", "when", "case", "label", "if", "next", "to", "target", "step"),
Expand Down
2 changes: 2 additions & 0 deletions src/Aevatar.Studio.Domain/Studio/Models/RoleModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ public sealed record RoleModel

public List<string>? AllowedTools { get; init; }

public List<string>? ToolSets { get; init; }

public string? EventModules { get; init; }

public string? EventRoutes { get; init; }
Expand Down
2 changes: 2 additions & 0 deletions src/Aevatar.Studio.Domain/Studio/Models/StepModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ public sealed record StepModel

public List<string>? AllowedTools { get; init; }

public List<string>? ToolSets { get; init; }

public StepCapability? Capability { get; init; }

public StudioStepParameters Parameters { get; init; } = new();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ private RoleModel NormalizeRole(RoleModel role)
Model = NormalizeText(role.Model),
EventModules = NormalizeText(role.EventModules),
EventRoutes = NormalizeText(role.EventRoutes),
AllowedTools = NormalizeAllowedTools(role.AllowedTools),
AllowedTools = NormalizeOptionalStringList(role.AllowedTools),
ToolSets = NormalizeOptionalStringList(role.ToolSets),
Connectors = role.Connectors
.SelectMany(SplitConnectorValue)
.Distinct(StringComparer.OrdinalIgnoreCase)
Expand Down Expand Up @@ -80,7 +81,8 @@ private StepModel NormalizeStep(StepModel step)
OriginalType = canonicalType,
TargetRole = NormalizeText(step.TargetRole),
UsedRoleAlias = false,
AllowedTools = NormalizeAllowedTools(step.AllowedTools),
AllowedTools = NormalizeOptionalStringList(step.AllowedTools),
ToolSets = NormalizeOptionalStringList(step.ToolSets),
Capability = NormalizeCapability(step.Capability),
Parameters = normalizedParameters,
Next = NormalizeText(step.Next),
Expand Down Expand Up @@ -196,10 +198,10 @@ private static void AddStringIfMissing(IDictionary<string, StudioStepParameterVa
private static string? NormalizeText(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();

private static List<string>? NormalizeAllowedTools(IReadOnlyList<string>? allowedTools) =>
allowedTools is null
private static List<string>? NormalizeOptionalStringList(IReadOnlyList<string>? values) =>
values is null
? null
: allowedTools
: values
.Select(NormalizeText)
.Where(value => value is not null)
.Select(value => value!)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,8 @@ private List<RoleModel> ParseRoles(YamlMappingNode root, ICollection<ValidationF
MaxTokens = ReadInteger(roleNode, "max_tokens", findings, path),
MaxToolRounds = ReadInteger(roleNode, "max_tool_rounds", findings, path),
MaxHistoryMessages = ReadInteger(roleNode, "max_history_messages", findings, path),
AllowedTools = ParseAllowedTools(roleNode, path, findings),
AllowedTools = ParseOptionalStringList(roleNode, "allowed_tools", path, findings),
ToolSets = ParseOptionalStringList(roleNode, "tool_sets", path, findings),
EventModules = eventModules,
EventRoutes = eventRoutes,
Connectors = ParseConnectors(roleNode, path, findings),
Expand Down Expand Up @@ -248,7 +249,8 @@ private StepModel ParseStep(YamlMappingNode stepNode, string path, ICollection<V
OriginalType = rawType,
TargetRole = ReadScalar(stepNode, "target_role") ?? ReadScalar(stepNode, "role"),
UsedRoleAlias = GetNode(stepNode, "target_role") is null && GetNode(stepNode, "role") is not null,
AllowedTools = ParseAllowedTools(stepNode, path, findings),
AllowedTools = ParseOptionalStringList(stepNode, "allowed_tools", path, findings),
ToolSets = ParseOptionalStringList(stepNode, "tool_sets", path, findings),
Capability = ParseCapability(stepNode, path, findings),
Parameters = parameters,
Next = ReadScalar(stepNode, "next"),
Expand Down Expand Up @@ -554,50 +556,76 @@ private List<string> ParseConnectors(
return [];
}

private static List<string>? ParseAllowedTools(
private static List<string>? ParseOptionalStringList(
YamlMappingNode node,
string key,
string path,
ICollection<ValidationFinding> findings)
{
var allowedToolsNode = GetNode(node, "allowed_tools");
if (allowedToolsNode is null)
var listNode = GetNode(node, key);
if (listNode is null)
{
return null;
}

if (allowedToolsNode is YamlSequenceNode sequenceNode)
if (listNode is YamlSequenceNode sequenceNode)
{
var tools = new List<string>();
var values = new List<string>();
for (var index = 0; index < sequenceNode.Children.Count; index++)
{
if (sequenceNode.Children[index] is not YamlScalarNode scalarNode)
{
findings.Add(ValidationFinding.Error(
$"{path}/allowed_tools/{index}",
"Each `allowed_tools` entry must be a string."));
$"{path}/{key}/{index}",
$"Each `{key}` entry must be a string."));
continue;
}

if (IsYamlNullScalar(scalarNode))
{
continue;
}

if (!string.IsNullOrWhiteSpace(scalarNode.Value))
{
tools.Add(scalarNode.Value.Trim());
values.Add(scalarNode.Value.Trim());
}
}

return tools;
return values;
}

if (allowedToolsNode is YamlScalarNode scalar)
if (listNode is YamlScalarNode scalar)
{
if (IsYamlNullScalar(scalar))
{
return null;
}

return (scalar.Value ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToList();
}

findings.Add(ValidationFinding.Error($"{path}/allowed_tools", "`allowed_tools` must be a list or comma-delimited string."));
findings.Add(ValidationFinding.Error($"{path}/{key}", $"`{key}` must be a list or comma-delimited string."));
return [];
}

private static bool IsYamlNullScalar(YamlScalarNode scalar)
{
if (scalar.Tag == "tag:yaml.org,2002:null")
{
return true;
}

if (!scalar.Tag.IsEmpty || scalar.Style != ScalarStyle.Plain)
{
return false;
}

return scalar.Value is null or "" or "~" or "null" or "Null" or "NULL";
}

private Dictionary<string, object?> SerializeRole(RoleModel role)
{
// Refactor (iter31/cluster-032-chatruntime-taskrun-business-loop):
Expand Down Expand Up @@ -625,6 +653,7 @@ private List<string> ParseConnectors(
AddIfNotNull(result, "max_tool_rounds", role.MaxToolRounds);
AddIfNotNull(result, "max_history_messages", role.MaxHistoryMessages);
AddIfPresent(result, "allowed_tools", role.AllowedTools);
AddIfPresent(result, "tool_sets", role.ToolSets);
AddIfNotNull(result, "event_modules", role.EventModules);
AddIfNotNull(result, "event_routes", role.EventRoutes);

Expand All @@ -650,6 +679,7 @@ private List<string> ParseConnectors(
}

AddIfPresent(result, "allowed_tools", step.AllowedTools);
AddIfPresent(result, "tool_sets", step.ToolSets);
AddIfNotNull(result, "capability", SerializeCapability(step.Capability));

if (step.Parameters.Count > 0)
Expand Down Expand Up @@ -739,10 +769,15 @@ private static void AddIfPresent(IDictionary<string, object?> dictionary, string
{
if (value is not null)
{
dictionary[key] = value;
dictionary[key] = value.Select(ToYamlStringListItem).ToList();
}
}

private static object ToYamlStringListItem(string value) =>
value is "null" or "Null" or "NULL" or "~"
? new YamlScalarNode(value) { Style = ScalarStyle.DoubleQuoted }
: value;

private static void AddIfNotBlank(
IDictionary<string, object?> dictionary,
string key,
Expand Down
51 changes: 51 additions & 0 deletions test/Aevatar.Studio.Tests/EditorControllerSerializationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,57 @@ public async Task ParseAndSerializeYaml_ShouldPreserveAllowedToolsInDocumentJson
serializeBody.Should().Contain("allowed_tools: []");
}

[Fact]
public async Task ParseAndSerializeYaml_ShouldPreserveToolSetsInDocumentJson()
{
using var host = await StartHostAsync();
var client = host.GetTestClient();

using var parseResponse = await client.PostAsJsonAsync("/api/editor/parse-yaml", new
{
yaml = """
name: tool_set_scope
roles:
- id: planner
tool_sets: [studio.local, nyxid.connected_services]
- id: isolated
tool_sets: []
steps:
- id: scoped
type: llm_call
target_role: planner
tool_sets: [nyxid.connected_services]
- id: no_tool_sets
type: llm_call
target_role: isolated
tool_sets: []
""",
availableStepTypes = new[] { "llm_call" },
});

var parseBody = await parseResponse.Content.ReadAsStringAsync();
parseResponse.StatusCode.Should().Be(HttpStatusCode.OK, parseBody);
parseBody.Should().NotContain("\"code\":\"unknown_field\"");
parseBody.Should().Contain("\"toolSets\":[\"studio.local\",\"nyxid.connected_services\"]");
parseBody.Should().Contain("\"toolSets\":[\"nyxid.connected_services\"]");
parseBody.Should().Contain("\"toolSets\":[]");

using var parsedJson = JsonDocument.Parse(parseBody);
var document = parsedJson.RootElement.GetProperty("document").Clone();
using var serializeResponse = await client.PostAsJsonAsync("/api/editor/serialize-yaml", new
{
document,
availableStepTypes = new[] { "llm_call" },
});

var serializeBody = await serializeResponse.Content.ReadAsStringAsync();
serializeResponse.StatusCode.Should().Be(HttpStatusCode.OK, serializeBody);
serializeBody.Should().Contain("tool_sets:");
serializeBody.Should().Contain("- studio.local");
serializeBody.Should().Contain("- nyxid.connected_services");
serializeBody.Should().Contain("tool_sets: []");
}

[Fact]
public async Task ParseAndSerializeYaml_ShouldPreserveTypedNyxIdCapabilitiesRecursively()
{
Expand Down
Loading
Loading