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 @@ -23,6 +23,9 @@ public class ComponentParameterListCliCommand : TxcLeafCommand
[CliArgument(Description = "Component type name, alias, template short name, or integer code (e.g. 'Entity', 'Table', 'pp-entity', '1').")]
public required string ShortName { get; set; }

[CliOption(Description = "Known parameter values in key=value form (repeatable). When given, only parameters whose isEnabled condition holds are listed (e.g. --param AttributeType=Text hides number/date/boolean-only parameters).")]
public List<string> Param { get; set; } = new();

protected override async Task<int> ExecuteAsync()
{
using var scaffolder = new TemplateInvoker();
Expand All @@ -33,6 +36,15 @@ protected override async Task<int> ExecuteAsync()
var resolvedShortName = resolved?.ShortNameList.FirstOrDefault() ?? ShortName;

var parameters = await scaffolder.ListParametersForTemplateAsync(resolvedShortName);

// When the caller supplies known values, drop parameters whose isEnabled condition
// evaluates to false for those values — keeps the listing relevant to the chosen type.
var providedValues = ParseParamValues(Param);
if (parameters != null && providedValues.Count > 0)
{
parameters = TemplateEngine.TemplateParameterConditionEvaluator
.FilterEnabled(parameters, providedValues);
}
if (parameters == null || parameters.Count == 0)
{
OutputFormatter.WriteList(Array.Empty<object>(), _ =>
Expand All @@ -52,6 +64,10 @@ protected override async Task<int> ExecuteAsync()
defaultValue = (string?)null,
required = true,
choices = (string?)null,
// appliesWhen/requiredWhen carry the template's conditional logic (e.g. a
// parameter that only applies when AttributeType == "Text"). Null = unconditional.
appliesWhen = (string?)null,
requiredWhen = (string?)null,
description = "Specifies the target project folder path where the component will be created. " +
"Required for both creating new projects (solution, plugin, PCF, etc.) and adding components to existing projects. " +
"Format: \"src\\Solutions.DataModel\""
Expand All @@ -65,6 +81,13 @@ protected override async Task<int> ExecuteAsync()
? string.Join(", ", p.Choices.Keys)
: null;

// Surface the template's conditional precedence so callers (and the MCP guide)
// know a parameter only applies / is only required under a condition.
var appliesWhen = string.IsNullOrWhiteSpace(p.Precedence.IsEnabledCondition)
? null : p.Precedence.IsEnabledCondition;
var requiredWhen = string.IsNullOrWhiteSpace(p.Precedence.IsRequiredCondition)
? null : p.Precedence.IsRequiredCondition;

projected.Add(new
{
name = p.Name,
Expand All @@ -73,6 +96,8 @@ protected override async Task<int> ExecuteAsync()
defaultValue = p.DefaultValue?.ToString(),
required = isRequired,
choices = choiceList,
appliesWhen,
requiredWhen,
description = p.Description
});
}
Expand All @@ -99,11 +124,28 @@ protected override async Task<int> ExecuteAsync()
if (!string.IsNullOrEmpty((string?)param.choices))
sb.Append($" choices: {param.choices}");
OutputWriter.WriteLine(sb.ToString());
if (!string.IsNullOrEmpty((string?)param.appliesWhen))
OutputWriter.WriteLine($" applies when: {param.appliesWhen}");
if (!string.IsNullOrEmpty((string?)param.requiredWhen))
OutputWriter.WriteLine($" required when: {param.requiredWhen}");
if (!string.IsNullOrEmpty((string?)param.description))
OutputWriter.WriteLine($" {param.description}");
}
});

return ExitSuccess;
}

private static Dictionary<string, string> ParseParamValues(IEnumerable<string> pairs)
{
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in pairs)
{
var idx = pair.IndexOf('=');
if (idx <= 0 || idx == pair.Length - 1)
throw new ArgumentException($"Invalid parameter format: '{pair}'. Use key=value.");
values[pair[..idx]] = pair[(idx + 1)..];
}
return values;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
namespace TALXIS.CLI.Features.Workspace.TemplateEngine;

/// <summary>
/// Minimal evaluator for the boolean expressions templates use in <c>isEnabled</c> /
/// <c>isRequired</c> conditions — string equality/inequality combined with <c>&amp;&amp;</c>,
/// <c>||</c> and parentheses, e.g. <c>(AttributeType == "Lookup") || (AttributeType == "Customer")</c>.
/// Identifiers resolve to supplied values (missing = empty string); a bare identifier is
/// truthy when its value is <c>"true"</c>.
/// </summary>
public static class TemplateConditionExpression
{
/// <summary>Evaluates <paramref name="expression"/> against <paramref name="variables"/>.</summary>
public static bool Evaluate(string expression, IReadOnlyDictionary<string, string> variables)
{
var tokens = Tokenize(expression);
var pos = 0;
var value = ParseOr(tokens, ref pos, variables);
return value;
}

private enum Kind { LParen, RParen, Or, And, Eq, Neq, Str, Ident }
private readonly record struct Token(Kind Kind, string Text);

private static List<Token> Tokenize(string s)
{
var tokens = new List<Token>();
var i = 0;
while (i < s.Length)
{
var c = s[i];
if (char.IsWhiteSpace(c)) { i++; continue; }
switch (c)
{
case '(': tokens.Add(new(Kind.LParen, "(")); i++; break;
case ')': tokens.Add(new(Kind.RParen, ")")); i++; break;
case '|' when Next(s, i) == '|': tokens.Add(new(Kind.Or, "||")); i += 2; break;
case '&' when Next(s, i) == '&': tokens.Add(new(Kind.And, "&&")); i += 2; break;
case '=' when Next(s, i) == '=': tokens.Add(new(Kind.Eq, "==")); i += 2; break;
case '!' when Next(s, i) == '=': tokens.Add(new(Kind.Neq, "!=")); i += 2; break;
case '"':
case '\'':
{
var quote = c;
var start = ++i;
while (i < s.Length && s[i] != quote) i++;
tokens.Add(new(Kind.Str, s[start..Math.Min(i, s.Length)]));
i++; // closing quote
break;
}
default:
{
var start = i;
while (i < s.Length && (char.IsLetterOrDigit(s[i]) || s[i] is '_' or '.' or '(' or ')'))
{
// Identifiers like OptionSet(Global) contain parens; only absorb them
// when they're clearly part of a value token, not grouping.
if (s[i] is '(' or ')') break;
i++;
}
if (i == start) { i++; break; } // skip unknown char
tokens.Add(new(Kind.Ident, s[start..i]));
break;
}
}
}
return tokens;
}

private static char Next(string s, int i) => i + 1 < s.Length ? s[i + 1] : '\0';

private static bool ParseOr(List<Token> t, ref int pos, IReadOnlyDictionary<string, string> vars)
{
var left = ParseAnd(t, ref pos, vars);
while (Peek(t, pos)?.Kind == Kind.Or)
{
pos++;
var right = ParseAnd(t, ref pos, vars);
left = left || right;
}
return left;
}

private static bool ParseAnd(List<Token> t, ref int pos, IReadOnlyDictionary<string, string> vars)
{
var left = ParsePrimary(t, ref pos, vars);
while (Peek(t, pos)?.Kind == Kind.And)
{
pos++;
var right = ParsePrimary(t, ref pos, vars);
left = left && right;
}
return left;
}

private static bool ParsePrimary(List<Token> t, ref int pos, IReadOnlyDictionary<string, string> vars)
{
var tok = Peek(t, pos);
if (tok is null) return false;

if (tok.Value.Kind == Kind.LParen)
{
pos++; // (
var inner = ParseOr(t, ref pos, vars);
if (Peek(t, pos)?.Kind == Kind.RParen) pos++; // )
return inner;
}

// Comparison: operand (== | !=) operand, or a bare operand used as a boolean.
var left = ParseOperand(t, ref pos, vars);
var op = Peek(t, pos);
if (op?.Kind == Kind.Eq || op?.Kind == Kind.Neq)
{
pos++;
var right = ParseOperand(t, ref pos, vars);
var equal = string.Equals(left, right, StringComparison.Ordinal);
return op.Value.Kind == Kind.Eq ? equal : !equal;
}
return string.Equals(left, "true", StringComparison.OrdinalIgnoreCase);
}

private static string ParseOperand(List<Token> t, ref int pos, IReadOnlyDictionary<string, string> vars)
{
var tok = Peek(t, pos);
if (tok is null) return "";
pos++;
return tok.Value.Kind switch
{
Kind.Str => tok.Value.Text,
Kind.Ident => vars.TryGetValue(tok.Value.Text, out var v) ? v : "",
_ => "",
};
}

private static Token? Peek(List<Token> t, int pos) => pos < t.Count ? t[pos] : null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using Microsoft.TemplateEngine.Abstractions;

namespace TALXIS.CLI.Features.Workspace.TemplateEngine;

/// <summary>
/// Evaluates a template parameter's <c>isEnabled</c> condition (e.g.
/// <c>(AttributeType == "Text")</c>) against a set of supplied values, so the parameter
/// listing can hide type-specific parameters that don't apply to the chosen values.
/// </summary>
public static class TemplateParameterConditionEvaluator
{
/// <summary>
/// Returns the names of the parameters that are enabled given <paramref name="providedValues"/>.
/// A parameter with no condition is always enabled. Each condition is evaluated against a
/// variable set built from every parameter's effective value (supplied value, else its default,
/// else empty), mirroring how the engine resolves disabled parameters to their defaults.
/// </summary>
public static IReadOnlyList<string> SelectEnabled(
IReadOnlyList<(string Name, string? EnabledCondition, string? DefaultValue)> parameters,
IReadOnlyDictionary<string, string> providedValues)
{
var variables = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var p in parameters)
variables[p.Name] = providedValues.TryGetValue(p.Name, out var supplied)
? supplied
: p.DefaultValue ?? "";
// Include any supplied keys that aren't declared parameters so conditions referencing
// them still resolve.
foreach (var kv in providedValues)
variables[kv.Key] = kv.Value;

var enabled = new List<string>();
foreach (var p in parameters)
{
if (string.IsNullOrWhiteSpace(p.EnabledCondition)
|| TemplateConditionExpression.Evaluate(p.EnabledCondition!, variables))
{
enabled.Add(p.Name);
}
}
return enabled;
}

/// <summary>
/// Filters template parameters down to those enabled for the supplied values.
/// </summary>
public static IReadOnlyList<ITemplateParameter> FilterEnabled(
IReadOnlyList<ITemplateParameter> parameters,
IReadOnlyDictionary<string, string> providedValues)
{
var enabledNames = SelectEnabled(
parameters.Select(p => (p.Name, p.Precedence?.IsEnabledCondition, p.DefaultValue?.ToString())).ToList(),
providedValues).ToHashSet(StringComparer.Ordinal);

return parameters.Where(p => enabledNames.Contains(p.Name)).ToList();
}
}
26 changes: 25 additions & 1 deletion src/TALXIS.CLI.MCP/GuideHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,18 @@ public class GuideHandler
private readonly ToolCatalog _catalog;
private readonly ActiveToolSet _activeToolSet;
private readonly GuideReasoningEngine? _reasoningEngine;
private readonly ITemplateParameterProvider? _templateParams;

public GuideHandler(ToolCatalog catalog, ActiveToolSet activeToolSet, GuideReasoningEngine? reasoningEngine = null)
public GuideHandler(
ToolCatalog catalog,
ActiveToolSet activeToolSet,
GuideReasoningEngine? reasoningEngine = null,
ITemplateParameterProvider? templateParams = null)
{
_catalog = catalog;
_activeToolSet = activeToolSet;
_reasoningEngine = reasoningEngine;
_templateParams = templateParams;
}

/// <summary>
Expand Down Expand Up @@ -76,6 +82,9 @@ public async Task<CallToolResult> HandleAsync(
var toolDefinitions = entries.Select(McpToolRegistry.BuildToolDefinition).ToList();
_activeToolSet.InjectTools(toolDefinitions);

// Attach authoritative template parameters when the recipe scaffolds.
recipeText = await EnrichRecipeWithTemplateParamsAsync(recipeText, entries, ct);

// Build structured response (includes recipe when available from sampling)
var response = BuildGuidanceResponse(entries, query, recipeText);

Expand Down Expand Up @@ -114,6 +123,9 @@ public async Task<CallToolResult> HandleWorkflowGuideAsync(
var toolDefinitions = entries.Select(McpToolRegistry.BuildToolDefinition).ToList();
_activeToolSet.InjectTools(toolDefinitions);

// Attach authoritative template parameters when the recipe scaffolds.
recipeText = await EnrichRecipeWithTemplateParamsAsync(recipeText, entries, ct);

var response = BuildGuidanceResponse(entries, query, recipeText);

return McpToolResultFactory.BuildTextResult(response);
Expand Down Expand Up @@ -179,6 +191,7 @@ 3. Then a numbered recipe with concrete steps
- Environment operations take 30s-5min — only use for inspection/deployment
- If a step depends on a previous step's output, say so
- Include error recovery hints for common failures (TALXISXSD001 = schema validation, TALXISGUID001 = duplicate GUIDs)
- If any step scaffolds a component via workspace_component_create, append a line ""TEMPLATES: <short-name>[, <short-name>...]"" (e.g. ""TEMPLATES: pp-entity, pp-entity-attribute"") naming the exact template short-names used. The real parameter list for those templates will be attached below your recipe — use those exact parameter names and choices, do not invent parameters.

{catalogPrompt}{internalSkillsContext}";

Expand Down Expand Up @@ -208,6 +221,17 @@ 3. Then a numbered recipe with concrete steps
return ParseToolNamesAndRecipeFromSamplingResponse(responseText);
}

/// <summary>
/// When the recipe scaffolds via <c>workspace_component_create</c>, fetches the real
/// parameters for each referenced template and appends an authoritative block so the
/// model uses genuine parameter names/types/choices/conditions instead of inventing
/// them. Best-effort: any lookup failure leaves the recipe unchanged.
/// </summary>
private Task<string?> EnrichRecipeWithTemplateParamsAsync(
string? recipeText, List<ToolCatalogEntry> entries, CancellationToken ct)
=> TemplateParameterEnricher.EnrichAsync(
recipeText, entries.Select(e => e.Descriptor.Name), _templateParams, ct);

/// <summary>
/// Parses tool names and recipe text from a sampling response.
/// Scans each line for a valid JSON string array (tool names), then extracts recipe text from lines after.
Expand Down
6 changes: 5 additions & 1 deletion src/TALXIS.CLI.MCP/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@

// Session-scoped active tool set — starts with always-on tools only
var activeToolSet = new ActiveToolSet();
var guideHandler = new GuideHandler(mcpToolRegistry.Catalog, activeToolSet, reasoningEngine);
// Resolves the workspace root lazily — rootsService is assigned later in startup,
// so the closure reads it at call time, not now.
var templateParameterProvider = new SubprocessTemplateParameterProvider(
async ct => rootsService is not null ? await rootsService.GetWorkingDirectoryAsync(ct) : null);
var guideHandler = new GuideHandler(mcpToolRegistry.Catalog, activeToolSet, reasoningEngine, templateParameterProvider);

// Register always-on tools (these are the only tools visible at session start)
RegisterAlwaysOnTools(activeToolSet, mcpToolRegistry, publicSkillLoader);
Expand Down
Loading