Add CacheVariables feature to TreeWalker#87
Conversation
Add CacheVariables property on TreeNode that evaluates Roslyn expressions after actions complete and makes results available in ShouldSelect via Cache.x. Key design: - Node-scoped: Cache is cleared at the start of each node visit - Uses EvaluateDynamicProperty: supports C#| expressions and string literals - Accessible as Cache.x in Roslyn expressions or Session.GetCache(name) - No persistence to ForgeState (ephemeral within node execution) Changes: - ForgeTree.cs: Add CacheVariables (Dictionary<string, string>) to TreeNode - ExpressionExecutor.cs: Add Cache (ExpandoObject) to CodeGenInputParams - ITreeSession.cs: Add GetCache(name) method - TreeWalkerSession.cs: Add ResolveCacheVariables, ClearCache in VisitNode - ForgeExceptions.cs: Add CacheVariableException - ForgeSchemaValidationRules.json: Add CacheVariables to node definitions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Simplify CacheVariables evaluation to match the existing pattern used by Properties, Input, and TreeInput. Instead of a custom ResolveCacheVariables method with per-variable iteration, CacheVariables is now typed as dynamic and evaluated via EvaluateDynamicProperty(treeNode.CacheVariables, null). Changes: - ForgeTree.cs: CacheVariables type changed from Dictionary<string,string> to dynamic - ExpressionExecutor.cs: Cache uses SetCache/ClearCache with JObject instead of ExpandoObject - TreeWalkerSession.cs: Removed ResolveCacheVariables, inline EvaluateDynamicProperty call - ForgeExceptions.cs: Removed CacheVariableException (uses existing EvaluateDynamicPropertyException) - Tests: Updated InvalidExpression test to expect EvaluateDynamicPropertyException Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- CacheVariables type: Dictionary<string,string> -> dynamic - Cache implementation: ExpandoObject -> JObject via EvaluateDynamicProperty - Error handling: CacheVariableException -> EvaluateDynamicPropertyException - Status: Failed_CacheVariable -> Failed_EvaluateDynamicProperty - Design decision 7.4: Updated rationale for JObject approach Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds support for node-scoped CacheVariables that are evaluated after a node’s actions complete and made available to selection logic (e.g., ShouldSelect) via a Cache dynamic object and a new Session.GetCache(...) API.
Changes:
- Introduces
CacheVariablesonTreeNodeand validates it in the schema rules. - Adds cache lifecycle management (clear per node, set after actions) and exposes cache access via
ITreeSession.GetCache. - Adds unit tests covering common
CacheVariablesscenarios and backward compatibility.
Reviewed changes
Copilot reviewed 7 out of 8 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| Forge.TreeWalker/src/TreeWalkerSession.cs | Clears cache per node visit, evaluates CacheVariables, exposes GetCache(name) API. |
| Forge.TreeWalker/src/ITreeSession.cs | Adds GetCache(string name) to the public session interface. |
| Forge.TreeWalker/src/ExpressionExecutor.cs | Adds Cache to Roslyn globals and cache get/set/clear helpers. |
| Forge.TreeWalker/contracts/ForgeTree.cs | Adds TreeNode.CacheVariables contract field. |
| Forge.TreeWalker/contracts/ForgeSchemaValidationRules.json | Allows CacheVariables as an object of string expressions in relevant node schemas. |
| Forge.TreeWalker.UnitTests/test/TreeWalkerUnitTests.cs | Adds test coverage for CacheVariables behavior. |
| Forge.TreeWalker.UnitTests/test/ForgeSchemaHelper.cs | Adds schema fixtures for CacheVariables tests. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| [DataMember] | ||
| public dynamic CacheVariables { get; set; } |
- GetCache: Use TryGetValue for safe null-return on missing keys (JObject and IDictionary) - ForgeTree.cs: Change CacheVariables to private set for contract immutability - ForgeSchemaHelper.cs: Fix comment to reference EvaluateDynamicPropertyException Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…y JObject - Add Newtonsoft.Json.Linq.JObject assembly reference to Roslyn script options so dynamic member access on the Cache JObject resolves at runtime - Initialize Cache to empty JObject (not null) to prevent NullReferenceException when ShouldSelect references Cache on nodes without CacheVariables - ClearCache resets to empty JObject instead of null Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| TreeNode treeNode = this.Schema.Tree[treeNodeKey]; | ||
|
|
||
| // Clear node-scoped cache variables at the start of each node visit. | ||
| this.expressionExecutor.ClearCache(); |
There was a problem hiding this comment.
Let's consider adding this ClearCache call directly after awaiting SelectChild. Doing that would definitely lock it down to only being used during SelectChild.
Having ClearCache at the top of VisitNode here means that AfterVisitNode would also have access to the Cache. And also if folks directly call VisitNode (without WalkTree), the Cache would be maintained after returning VisitNode, so app (or UTs) could then access the Cache as well.
Let's discuss offline if there are valid use-cases or any drawbacks to calling ClearCache at the beginning of VisitNode vs at the end.
|
|
||
| // Evaluate CacheVariables after actions complete, before selecting child. | ||
| // Uses the same EvaluateDynamicProperty pattern as Properties/Input. | ||
| object cacheResult = await this.EvaluateDynamicProperty(treeNode.CacheVariables, null).ConfigureAwait(false); |
There was a problem hiding this comment.
nit: Let's add this after the if Leaf statement. Since it won't be needed if that returns.
| // Evaluate CacheVariables after actions complete, before selecting child. | ||
| // Uses the same EvaluateDynamicProperty pattern as Properties/Input. | ||
| object cacheResult = await this.EvaluateDynamicProperty(treeNode.CacheVariables, null).ConfigureAwait(false); | ||
| this.expressionExecutor.SetCache(cacheResult); |
There was a problem hiding this comment.
Idea to have a SetCache method in TreeWalkerSession. It would do these 2 operations, EvaluateDynamicProperty and SetCache in ExpressionExecutor.
The benefit of having a SetCache method is for UTs that validate schemas by evaluating all the expressions. For example, a UT that evaluates the ShouldSelect statements by calling EvaluateDynamicProperty. With the new Cache feature, ShouldSelect statements would fail unless the Cache is filled with the expected values. So we need some way to update the Cache with expected values. Something like:
await treeWalkerSession.SetCache(forgeTreeNode.CacheVariables); await treewalkerSession.EvaluateDynamicProperty(childSelector, typeof(bool));
| /// </summary> | ||
| /// <param name="name">The variable name as defined in CacheVariables.</param> | ||
| /// <returns>The cached value if it exists, otherwise null.</returns> | ||
| public object GetCache(string name) |
There was a problem hiding this comment.
Is this called anywhere? I don't understand the need for this method.
| Session = session, | ||
| TreeInput = treeInput | ||
| TreeInput = treeInput, | ||
| Cache = new Newtonsoft.Json.Linq.JObject() |
There was a problem hiding this comment.
TreeInput is already dynamic and is handled. Can we have Cache treated similarly? I don't think we need to set it as a JObject or add an additional assembly for that.
I think setting to null here is fine. If folks accidentally try to use the Cache (from ShouldSelect) when there's nothing inside (Cache in the schema), it would throw a NullReferenceException, which should be pretty clear that it's because there's no Cache defined (in the schema). Versus if we set this Cache to a default Object instead, it would throw a more confusing exception in the same scenario.
| /// </summary> | ||
| public void ClearCache() | ||
| { | ||
| this.parameters.Cache = new Newtonsoft.Json.Linq.JObject(); |
There was a problem hiding this comment.
I think setting to null here is fine.
| /// Gets the Cache object (a JObject with evaluated CacheVariable values, or null). | ||
| /// </summary> | ||
| /// <returns>The cache object.</returns> | ||
| public dynamic GetCache() |
There was a problem hiding this comment.
I think this can return "object" type instead of dynamic. I see that GetOrCommitTreeInput returns object.
| /// Cache variables are scoped to the current node only — they are cleared when moving to the next node. | ||
| /// </summary> | ||
| [DataMember] | ||
| public dynamic CacheVariables { get; private set; } |
There was a problem hiding this comment.
Consider just "Cache" for the name.
There was a problem hiding this comment.
There's an argument that "CacheVariables" is more clear to what it does. At first glance at a schema, who's to say what a "Cache" could represent..
"Variables" takes forever to say though, 😄.. Since this is what is shown to the user in the schema, let's brainstorm best name. Here's some ideas:
- Cache
- CacheVariables
- CacheVars
- TreeNodeCache
| "Properties": { | ||
| "type": "object" | ||
| }, | ||
| "CacheVariables": { |
There was a problem hiding this comment.
I think this is right, setting type as object and setting patternProperties. This should effectively enforce it is used like a dictionary with named, string properties. As opposed to "Properties" above that's simply an object. It can be set to a dynamic object, a string, an int, a dictionary, etc..
In theory, we could also set CacheVariables to object type like Properties. It would be more flexible/versatile for users, like if they want to simply set a single value instead of using the named properties. But I think I like the guardrails of enforcing a dictionary-style pattern. It'll make it more consistent and less error-prone.
| ""Tree"": { | ||
| ""Root"": { | ||
| ""Type"": ""Action"", | ||
| ""Actions"": { |
There was a problem hiding this comment.
Recommend removing the Actions and just making these Selection TreeNode types, unless you are utilizing the ActionResponse. Only include things that contribute to each UT in some way and cut the fluff please.
| ""SecondNode"": { | ||
| ""Type"": ""Selection"", | ||
| ""CacheVariables"": { | ||
| ""secondNodeCheck"": ""C#|Session.GetCache(\""firstNodeVar\"") == null ? \""isolated\"" : \""leaked\"""" |
There was a problem hiding this comment.
Session.GetCache I don't believe is a use-case. You should not be able to access the Cache from inside the Cache. Please remove this.
| /// <summary> | ||
| /// CacheVariables with string literal (non-Roslyn value). | ||
| /// </summary> | ||
| public const string CacheVariables_StringLiteral = @" |
There was a problem hiding this comment.
More UT ideas:
- CacheVariables_IsString_ExpectException - Set the Cache directly to a string instead of a dictionary, like: ""Cache"": ""value"". Maybe put it in a separate InvalidTestSchemas and confirm exception is thrown. Or maybe a forge schema validation test would be appropriate to ensure schema validation catches that issue.
- Set a CacheVariable value as an object, like ActionResponse. Then confirm ShouldSelect can use it like Cache.DiagnosticsActionResponse.Status
- Set a CacheVariable value as a boolean expression like Session.GetOutput(""Root_CollectDiagnosticsAction"").Status == ""Success"". Then confirm ShouldSelect can use it like Cache.IsSuccess.
There was a problem hiding this comment.
- Using all the TreeNode Types, like Selection, Leaf, and Subroutine. Just simple tests to confirm Cache works on all the types.
There was a problem hiding this comment.
- Have 2 TreeNodes that have the same exact named property in their Cache. Confirm that the second TreeNode's ShouldSelect statement contains its expected value. This is a good confirmation that the Cache truly gets wiped.
| ""CacheVariables"": { | ||
| ""a"": ""C#|10"", | ||
| ""b"": ""C#|20"", | ||
| ""sum"": ""C#|30"" |
There was a problem hiding this comment.
nit: It's minor, but it could lead to confusion. You list variables, "a", "b", and "sum". This could imply that you could calculate sum by USING what you just set in a and b. But this is not possible, you can't view what's in the Cache from CacheVariables. So just to avoid that possible confusion, can you change the example?
TravisOnGit
left a comment
There was a problem hiding this comment.
Left feedback on iteration 7. Please have a look, thanks!
Summary
Adds a new CacheVariables property to TreeNode that enables declarative, node-scoped variable caching within workflow schemas. Schema authors can evaluate expressions after actions complete and bind results to named variables accessible in ShouldSelect routing expressions via the Cache object.
Problem
Current ShouldSelect expressions must reference action outputs inline with repeated Session.GetOutput() calls, leading to verbose, hard-to-read routing logic.
Solution
\\json
"CacheVariables": {
"scsSucceeded": "C#|Session.GetOutput("GetBypassSafetyCheckToken").Output.Succeeded",
"scsIsSafe": "C#|Session.GetOutput("GetBypassSafetyCheckToken").Output.IsSafe"
},
"ChildSelector": [
{ "ShouldSelect": "C#|Cache.scsSucceeded && Cache.scsIsSafe", "Child": "Next" }
]
\\
Key Design Decisions
Files Changed
Testing