From a7aaaf4b7a46bc376df4a14dfb3c85d00bfe897d Mon Sep 17 00:00:00 2001 From: Rick Hennigan Date: Thu, 8 Oct 2026 15:50:23 +0000 Subject: [PATCH 1/2] Parse Local-method evaluator code with the session's context state Under the "Local" method, Chatbook parses the tool's code string in the server kernel and only evaluates it in the sandbox subkernel, so typed symbols were created in the server kernel's Global` context (shared by all sessions) and names resolved against the server kernel's packages instead of the session's. Before each evaluation, withSession now calls syncParseContext, which asks the eval subkernel for its $Context, $ContextPath, and $ContextAliases and for the symbol each short name in the code resolves to there, creates those symbols in the server kernel, and applies the same context state for the duration of the call. Packages loaded only in the session (Get/Needs, including aliases) therefore resolve by short name, and the false Symbol::undefined warnings for session definitions disappear. In-process methods are unaffected. Fixes #249 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0126WqqLgeZKSK3FkAmUxRpG --- Kernel/Tools/WolframLanguageEvaluator.wl | 129 ++++++++++++-- Tests/EvaluatorSessions.wlt | 215 +++++++++++++++++++++-- 2 files changed, 322 insertions(+), 22 deletions(-) diff --git a/Kernel/Tools/WolframLanguageEvaluator.wl b/Kernel/Tools/WolframLanguageEvaluator.wl index 4cbfd64c..7c6e6f55 100644 --- a/Kernel/Tools/WolframLanguageEvaluator.wl +++ b/Kernel/Tools/WolframLanguageEvaluator.wl @@ -121,6 +121,7 @@ evaluateWolframLanguage[ args: KeyValuePattern[ "code" -> code_ ] ] := session = Lookup[ args, "session", Missing[ "session" ] ]; withSession[ session, + code, If[ TrueQ @ $clientSupportsUI && TrueQ @ $deployCloudNotebooks, evaluateWolframLanguageUI[ code, timeConstraint ], evaluateWolframLanguage[ code, timeConstraint ] @@ -555,10 +556,11 @@ initializePacletInLocalKernel // endDefinition; (*Sessions*) (* Each conversation gets an isolated, resumable evaluation session, keyed by an opaque ID supplied by the AI via the "session" parameter. Isolation comes from a per-session $Context: the session functions - run via useEvaluatorKernel and point the controlling kernel's $Context at "Sessions``", so the - user's code parses into that context (and, under the "Local" method, the resulting fully qualified - symbols then evaluate in that context inside the eval subkernel). State is persisted to disk so sessions - survive server restarts. + run via useEvaluatorKernel and point the eval kernel's $Context at "Sessions``", so the user's code + parses into that context. Under the in-process methods the eval kernel is this kernel. Under the "Local" + method the code is parsed here but evaluated in the eval subkernel, so syncParseContext also points this + kernel's parser at the subkernel's session state (see Local Parse Context). State is persisted to disk so + sessions survive server restarts. Line numbering is owned by the file-scoped $line: the authoritative per-session counter, persisted in the session payload and passed as the "Line" option. Under the in-process "Session" method that option @@ -735,9 +737,8 @@ restoreCloudSessionState // endDefinition; $currentSessionID and the file-scoped $line. Under in-process methods parsing, evaluation, and session state all live in this kernel. Under the "Local" method useEvaluatorKernel runs these in the persistent eval subkernel alongside the user's evaluations, whose $Line is seeded separately via - syncEvalKernelLine. (Note: under "Local", Chatbook parses code strings in the controlling kernel, so - parse-time binding of unqualified new symbols does not see the eval kernel's session contexts \[LongDash] - a known limitation of that method.) *) + syncEvalKernelLine, and whose context state syncParseContext mirrors into this kernel for parsing (see + Local Parse Context). *) (* ::**************************************************************************************************************:: *) (* ::Subsubsection::Closed:: *) @@ -1091,6 +1092,111 @@ resumeSessionInKernel[ path_String ] := (* :!CodeAnalysis::EndBlock:: *) resumeSessionInKernel // endDefinition; +(* ::**************************************************************************************************************:: *) +(* ::Subsection::Closed:: *) +(*Local Parse Context*) +(* Under the "Local" method the user's code is evaluated in the eval subkernel, but Chatbook parses the code + string in this kernel first, and the parse decides which symbol each short name refers to. Left to this + kernel's own context state, typed symbols would land in Global` (shared by every session) and names would + resolve against this kernel's packages instead of the session's. So before each evaluation + syncParseContext asks the eval kernel which existing symbol each short name in the code resolves to there, + creates those symbols here (this kernel may not have loaded the session's packages), and applies the + session's $Context, $ContextPath, and $ContextAliases to this kernel, scoped to the call by withSession. + Names that resolve to nothing in the eval kernel are then created in the session context, as they would be + there. The in-process methods parse where they evaluate, so this is a no-op for them. *) + +(* ::**************************************************************************************************************:: *) +(* ::Subsubsection::Closed:: *) +(*syncParseContext*) +syncParseContext // beginDefinition; + +(* :!CodeAnalysis::BeginBlock:: *) +(* :!CodeAnalysis::Disable::SuspiciousSessionSymbol:: *) +syncParseContext[ code_String ] /; getEvaluatorMethod[ ] === "Local" := Enclose[ + Module[ { state }, + state = ConfirmMatch[ + (* Inject the literal names for the same reason as in saveSession. *) + With[ { names = symbolNameCandidates @ code }, useEvaluatorKernel @ parseContextInKernel @ names ], + KeyValuePattern @ { + "$Context" -> _String, + "$ContextPath" -> { ___String }, + "$ContextAliases" -> _Association, + "Symbols" -> { ___String } + }, + "State" + ]; + (* Full names, so no aliases may apply; Quiet suppresses General::shdw for names that exist in several + contexts here. *) + Block[ { $ContextAliases = <| |> }, + Quiet @ Scan[ ToExpression[ #, InputForm, Hold ] &, state[ "Symbols" ] ] + ]; + $Context = state[ "$Context" ]; + $ContextPath = state[ "$ContextPath" ]; + $ContextAliases = state[ "$ContextAliases" ]; + ], + throwInternalFailure +]; +(* :!CodeAnalysis::EndBlock:: *) + +syncParseContext[ _String ] := Null; + +syncParseContext // endDefinition; + +(* ::**************************************************************************************************************:: *) +(* ::Subsubsection::Closed:: *) +(*syncParseContextSafe*) +(* Parse-context bookkeeping must never abort the user's evaluation (mirrors syncEvalKernelLineSafe). *) +syncParseContextSafe // beginDefinition; +syncParseContextSafe[ code_String ] := Quiet @ catchAlways @ syncParseContext @ code; +syncParseContextSafe[ _ ] := Null; +syncParseContextSafe // endDefinition; + +(* ::**************************************************************************************************************:: *) +(* ::Subsubsection::Closed:: *) +(*symbolNameCandidates*) +(* The short names that appear in a code string. A lexical scan is enough because extra candidates (words in + strings and comments, the parts of qualified names) are only looked up, never created unless the eval + kernel already has them. Named-character letters such as \[Alpha] are unescaped first so that they count as + letters. *) +symbolNameCandidates // beginDefinition; + +symbolNameCandidates[ code_String ] := DeleteDuplicates @ StringCases[ + StringReplace[ code, esc: ("\\[" ~~ LetterCharacter.. ~~ "]") :> unescapeLetter @ esc ], + (LetterCharacter | "$") ~~ (WordCharacter | "$")... +]; + +symbolNameCandidates // endDefinition; + + +unescapeLetter // beginDefinition; +unescapeLetter[ esc_String ] := + With[ { c = Quiet @ ToExpression[ "\"" <> esc <> "\"" ] }, If[ StringQ @ c && LetterQ @ c, c, esc ] ]; +unescapeLetter // endDefinition; + +(* ::**************************************************************************************************************:: *) +(* ::Subsubsection::Closed:: *) +(*parseContextInKernel*) +(* Runs in the eval kernel: its context state, plus the full name of the existing symbol that each given short + name resolves to there. Context[name] performs the kernel's own lookup ($ContextPath, then $Context) without + creating a symbol; names that resolve to nothing are left out. *) +parseContextInKernel // beginDefinition; +(* :!CodeAnalysis::BeginBlock:: *) +(* :!CodeAnalysis::Disable::SuspiciousSessionSymbol:: *) +parseContextInKernel[ names: { ___String } ] := <| + "$Context" -> $Context, + "$ContextPath" -> $ContextPath, + "$ContextAliases" -> $ContextAliases, + "Symbols" -> Map[ resolveSymbolName, names ] +|>; +(* :!CodeAnalysis::EndBlock:: *) +parseContextInKernel // endDefinition; + + +resolveSymbolName // beginDefinition; +resolveSymbolName[ name_String ] := + With[ { context = Quiet @ Context @ name }, If[ StringQ @ context, context <> name, Nothing ] ]; +resolveSymbolName // endDefinition; + (* ::**************************************************************************************************************:: *) (* ::Subsection::Closed:: *) (*Session Cleanup*) @@ -1259,7 +1365,8 @@ saveSessionSafe // endDefinition; (* ::Subsubsection::Closed:: *) (*withSession*) (* Single choke point wrapping both eval paths: set up the session, evaluate, save, then append the - session info. HoldRest defers the evaluation until the session is active. Internal`InheritedBlock + session info. HoldRest defers the evaluation until the session is active; the code string is only needed + to set up parsing under the "Local" method (see Local Parse Context). Internal`InheritedBlock scopes $Context/$ContextPath/$ContextAliases to this call: the session context is active during the evaluation and the save, then restored to the kernel's neutral baseline afterward. Symbols created during the block (the user's definitions) persist in their session context, as do the session's @@ -1270,15 +1377,17 @@ saveSessionSafe // endDefinition; withSession // beginDefinition; withSession // Attributes = { HoldRest }; -withSession[ session_, eval_ ] := +withSession[ session_, code_, eval_ ] := Internal`InheritedBlock[ { $Context, $ContextPath, $ContextAliases }, Block[ { cb`$CloudSessionMX = None }, Module[ { id, result }, id = applySession @ session; (* applySession has set $line and the session $Context. For the "Local" method also push $line into the eval subkernel's $Line at a boundary (a continued session already tracks - it in lockstep); no-op for in-process methods. *) + it in lockstep), and point this kernel's parser at the subkernel's session state; both + are no-ops for in-process methods. *) If[ $sessionStatus =!= "continued", syncEvalKernelLineSafe @ $line ]; + syncParseContextSafe @ code; result = eval; saveSessionSafe @ id; appendSessionInfo[ result, id ] diff --git a/Tests/EvaluatorSessions.wlt b/Tests/EvaluatorSessions.wlt index 72d612dd..be1580b1 100644 --- a/Tests/EvaluatorSessions.wlt +++ b/Tests/EvaluatorSessions.wlt @@ -743,6 +743,96 @@ VerificationTest[ TestID -> "SessionInfoStatusText-InKernelHasNoCloudNotice@@Tests/EvaluatorSessions.wlt:734,1-744,2" ] +(* ::**************************************************************************************************************:: *) +(* ::Section::Closed:: *) +(*Local Parse Context*) +(* Under the "Local" method Chatbook parses the code in this kernel but evaluates it in the eval subkernel, so + the parse is given the eval kernel's context state and the eval kernel's symbols for the code's names. *) + +(* Words in strings and comments are extra candidates, which is harmless; named-character letters are + unescaped so that they belong to a name, while operators such as \[Equal] still separate names. *) +VerificationTest[ + Wolfram`AgentTools`Tools`WolframLanguageEvaluator`Private`symbolNameCandidates[ + "f[x_Integer] := \\[Alpha]1 + $v2 + ctx`g + a\\[Equal]b (* note *) + \"word\" + f[2x]" + ], + { "f", "x", "Integer", "\[Alpha]1", "$v2", "ctx", "g", "a", "Equal", "b", "note", "word" }, + SameTest -> MatchQ, + TestID -> "SymbolNameCandidates-GH#249@@Tests/EvaluatorSessions.wlt:754,1-761,2" +] + +(* The eval kernel reports its context state and the full name of the existing symbol each name resolves to + ($ContextPath first, then $Context), and does not create symbols for the names that resolve to nothing. *) +VerificationTest[ + Module[ { result }, + result = Block[ { $Context = "ParseCtxTestA`", $ContextPath = { "ParseCtxTestB`", "System`" } }, + ParseCtxTestA`pctShared = 1; + ParseCtxTestB`pctShared = 2; + ParseCtxTestA`pctOnlyA = 3; + { + Wolfram`AgentTools`Tools`WolframLanguageEvaluator`Private`parseContextInKernel[ + { "pctShared", "pctOnlyA", "pctMissing", "Plus" } + ], + NameQ[ "ParseCtxTestA`pctMissing" ] + } + ]; + Quiet @ Remove[ "ParseCtxTestA`*", "ParseCtxTestB`*" ]; + result + ], + { + <| + "$Context" -> "ParseCtxTestA`", + "$ContextPath" -> { "ParseCtxTestB`", "System`" }, + "$ContextAliases" -> _Association, + "Symbols" -> { "ParseCtxTestB`pctShared", "ParseCtxTestA`pctOnlyA", "System`Plus" } + |>, + False + }, + SameTest -> MatchQ, + TestID -> "ParseContextInKernel-ResolvesExistingNames-GH#249@@Tests/EvaluatorSessions.wlt:765,1-792,2" +] + +(* With the eval kernel stubbed: the symbols it reports are created here although this kernel never loaded + their package, and its context state is applied, so a following parse resolves short and aliased names to + the eval kernel's symbols and creates new names in the session context. *) +VerificationTest[ + Module[ { result }, + result = Internal`InheritedBlock[ { $Context, $ContextPath, $ContextAliases }, + Block[ + { + Wolfram`AgentTools`Common`$toolOptions = <| "WolframLanguageEvaluator" -> <| "Method" -> "Local" |> |>, + Wolfram`AgentTools`Common`useEvaluatorKernel = Function[ eval, eval, HoldAllComplete ], + Wolfram`AgentTools`Tools`WolframLanguageEvaluator`Private`parseContextInKernel = <| + "$Context" -> "Sessions`PctStubSess`", + "$ContextPath" -> { "PctStubPkg`", "Sessions`PctStubSess`", "System`" }, + "$ContextAliases" -> <| "pcs`" -> "PctStubPkg`" |>, + "Symbols" -> { "PctStubPkg`pctStubFn" } + |> & + }, + Wolfram`AgentTools`Tools`WolframLanguageEvaluator`Private`syncParseContext[ "pctStubFn[1] + pctStubNew" ]; + { $Context, ToExpression[ #, InputForm, Context ] & /@ { "pctStubFn", "pcs`pctStubFn", "pctStubNew" } } + ] + ]; + Quiet @ Remove[ "PctStubPkg`*", "Sessions`PctStubSess`*" ]; + result + ], + { "Sessions`PctStubSess`", { "PctStubPkg`", "PctStubPkg`", "Sessions`PctStubSess`" } }, + SameTest -> MatchQ, + TestID -> "SyncParseContext-AppliesEvalKernelState-GH#249@@Tests/EvaluatorSessions.wlt:797,1-821,2" +] + +(* In-process methods parse where they evaluate, so this kernel's context state is left alone. *) +VerificationTest[ + Internal`InheritedBlock[ { $Context, $ContextPath, $ContextAliases }, + Block[ { Wolfram`AgentTools`Common`$toolOptions = <| |> }, + Wolfram`AgentTools`Tools`WolframLanguageEvaluator`Private`syncParseContext[ "x + 1" ]; + { $Context, $ContextPath, $ContextAliases } + ] + ] === { $Context, $ContextPath, $ContextAliases }, + True, + SameTest -> MatchQ, + TestID -> "SyncParseContext-NoOpForInProcess-GH#249@@Tests/EvaluatorSessions.wlt:824,1-834,2" +] + (* ::**************************************************************************************************************:: *) (* ::Section::Closed:: *) (*Integration: end-to-end session behavior*) @@ -768,7 +858,7 @@ VerificationTest[ StringContainsQ[ extractToolText @ r3, "42" ] ], True, - TestID -> "Integration-SessionIsolation@@Tests/EvaluatorSessions.wlt:753,1-772,2" + TestID -> "Integration-SessionIsolation@@Tests/EvaluatorSessions.wlt:843,1-862,2" ] (* Re-passing the same session ID continues it: definitions persist and line numbers advance. *) @@ -790,7 +880,7 @@ VerificationTest[ StringContainsQ[ text, "6" ] && StringContainsQ[ text, "Out[2]" ] ], True, - TestID -> "Integration-ContinueSamePersistsAndAdvancesLine@@Tests/EvaluatorSessions.wlt:775,1-794,2" + TestID -> "Integration-ContinueSamePersistsAndAdvancesLine@@Tests/EvaluatorSessions.wlt:865,1-884,2" ] (* A session resumes from disk after its in-kernel symbols are gone (simulated server restart). *) @@ -814,7 +904,7 @@ VerificationTest[ StringContainsQ[ extractToolText @ r2, "99" ] ], True, - TestID -> "Integration-RestartResumeFromDisk@@Tests/EvaluatorSessions.wlt:797,1-818,2" + TestID -> "Integration-RestartResumeFromDisk@@Tests/EvaluatorSessions.wlt:887,1-908,2" ] (* Every result echoes the session ID with resume instructions. *) @@ -834,7 +924,7 @@ VerificationTest[ StringContainsQ[ extractToolText @ r, "session=\"AppendSession\"" ] ], True, - TestID -> "Integration-AppendsSessionInfo@@Tests/EvaluatorSessions.wlt:821,1-838,2" + TestID -> "Integration-AppendsSessionInfo@@Tests/EvaluatorSessions.wlt:911,1-928,2" ] (* A fresh session's first evaluation is labeled Out[1]. *) @@ -854,7 +944,7 @@ VerificationTest[ StringContainsQ[ extractToolText @ r, "Out[1]" ] ], True, - TestID -> "Integration-FreshSessionStartsAtLineOne@@Tests/EvaluatorSessions.wlt:841,1-858,2" + TestID -> "Integration-FreshSessionStartsAtLineOne@@Tests/EvaluatorSessions.wlt:931,1-948,2" ] (* Resuming a session continues its line numbering rather than resetting it: A reaches Out[2], B @@ -879,7 +969,7 @@ VerificationTest[ StringContainsQ[ extractToolText @ r, "Out[3]" ] ], True, - TestID -> "Integration-ResumeContinuesLineNumbering@@Tests/EvaluatorSessions.wlt:863,1-883,2" + TestID -> "Integration-ResumeContinuesLineNumbering@@Tests/EvaluatorSessions.wlt:953,1-973,2" ] (* An unknown / expired session ID starts a fresh session reusing that ID and says so. *) @@ -899,7 +989,7 @@ VerificationTest[ StringContainsQ[ text, "NeverSavedXyz" ] && StringContainsQ[ text, "No saved state" ] ], True, - TestID -> "Integration-UnknownIdReusedFresh@@Tests/EvaluatorSessions.wlt:886,1-903,2" + TestID -> "Integration-UnknownIdReusedFresh@@Tests/EvaluatorSessions.wlt:976,1-993,2" ] (* Context-path changes made inside a session (e.g. by Get) survive continued calls: the continuing @@ -923,7 +1013,7 @@ VerificationTest[ StringContainsQ[ extractToolText @ r2, "{101, True}" ] ], True, - TestID -> "Integration-ContinuePreservesContextPath@@Tests/EvaluatorSessions.wlt:908,1-927,2" + TestID -> "Integration-ContinuePreservesContextPath@@Tests/EvaluatorSessions.wlt:998,1-1017,2" ] (* Resuming a session saved by a different kernel process restores the saved state and warns that @@ -953,7 +1043,7 @@ VerificationTest[ ], { True, True }, SameTest -> MatchQ, - TestID -> "Integration-ResumeFromPreviousKernelWarns@@Tests/EvaluatorSessions.wlt:932,1-957,2" + TestID -> "Integration-ResumeFromPreviousKernelWarns@@Tests/EvaluatorSessions.wlt:1022,1-1047,2" ] (* Switching back to an earlier session within the same kernel process resumes silently: no @@ -978,7 +1068,7 @@ VerificationTest[ ], { True, False }, SameTest -> MatchQ, - TestID -> "Integration-SameKernelResumeHasNoWarning@@Tests/EvaluatorSessions.wlt:961,1-982,2" + TestID -> "Integration-SameKernelResumeHasNoWarning@@Tests/EvaluatorSessions.wlt:1051,1-1072,2" ] (* If the eval kernel loses its in-memory session state while the session is still current (e.g. the @@ -1009,7 +1099,108 @@ VerificationTest[ ], { True, True, False }, SameTest -> MatchQ, - TestID -> "Integration-ContinueFallsBackToFileWhenKernelStateLost@@Tests/EvaluatorSessions.wlt:987,1-1013,2" + TestID -> "Integration-ContinueFallsBackToFileWhenKernelStateLost@@Tests/EvaluatorSessions.wlt:1077,1-1103,2" +] + +(* ::**************************************************************************************************************:: *) +(* ::Section::Closed:: *) +(*Integration: Local method*) +(* The real tool under the "Local" method, which evaluates in a Chatbook sandbox subkernel but parses the code + in this kernel. Skipped when the sandbox kernel cannot be started. *) +$localMethodOptions = <| "WolframLanguageEvaluator" -> <| "Method" -> "Local" |> |>; + +$localSessionTest = conditionalTest @ MatchQ[ + Quiet @ Wolfram`Chatbook`WolframLanguageToolEvaluate[ "1 + 1", "Result", "Method" -> "Local" ], + (HoldForm|HoldCompleteForm)[ 2 ] +]; + +withLocalSessions // Attributes = { HoldFirst }; +withLocalSessions[ eval_ ] := + Module[ { root }, + root = FileNameJoin @ { $TemporaryDirectory, "AgentToolsLocalSession_" <> CreateUUID[ ] }; + WithCleanup[ + Block[ + { + Wolfram`AgentTools`Common`$rootPath = root, + Wolfram`AgentTools`Common`$clientSupportsUI = False, + Wolfram`AgentTools`Common`$toolOptions = $localMethodOptions, + Wolfram`AgentTools`Tools`WolframLanguageEvaluator`Private`$currentSessionID = None + }, + eval + ], + Quiet @ DeleteDirectory[ root, DeleteContents -> True ] + ] + ]; + +localToolText[ code_String, session_String ] := + extractToolText @ $DefaultMCPTools[ "WolframLanguageEvaluator" ][ <| "code" -> code, "session" -> session |> ]; + +(* Typed symbols are created in the session context, so another session does not see them, and names are not + resolved against the packages loaded in this kernel. *) +$localSessionTest @ VerificationTest[ + withLocalSessions @ { + localToolText[ "localIsoX = 1; {Context[localIsoX], Context[CellToString], Context[StartMCPServer]}", "LocalIsoA" ], + localToolText[ "{localIsoX, Context[localIsoX]}", "LocalIsoB" ] + }, + { + _? (StringContainsQ[ "{\"Sessions`LocalIsoA`\", \"Sessions`LocalIsoA`\", \"Sessions`LocalIsoA`\"}" ]), + _? (StringContainsQ[ "{localIsoX, \"Sessions`LocalIsoB`\"}" ]) + }, + SameTest -> MatchQ, + TestID -> "Integration-Local-TypedSymbolsInSessionContext-GH#249@@Tests/EvaluatorSessions.wlt:1140,21-1151,2" +] + +(* Later calls resolve names through the contexts and aliases the session added: a package loaded only in the + eval kernel and an alias given to Needs. *) +$localSessionTest @ VerificationTest[ + Module[ { pkg, text }, + pkg = FileNameJoin @ { $TemporaryDirectory, "LocalPkg249_" <> CreateUUID[ ] <> ".wl" }; + Export[ + pkg, + "BeginPackage[\"LocalPkg249`\"];\nlocalPkgFn::usage = \"\";\nBegin[\"`Private`\"];\n" <> + "localPkgFn[x_] := x + 1000;\nEnd[];\nEndPackage[];\n", + "Text" + ]; + text = Last @ withLocalSessions @ { + localToolText[ "Get[" <> ToString[ pkg, InputForm ] <> "]; Needs[\"Developer`\" -> \"dv`\"]", "LocalPkgSess" ], + localToolText[ "{localPkgFn[1], Context[localPkgFn], dv`PackedArrayQ[Range[3]]}", "LocalPkgSess" ] + }; + Quiet @ DeleteFile @ pkg; + { StringContainsQ[ text, "{1001, \"LocalPkg249`\", True}" ], StringFreeQ[ text, "::shdw" ] } + ], + { True, True }, + SameTest -> MatchQ, + TestID -> "Integration-Local-SessionContextPathAndAliases-GH#249@@Tests/EvaluatorSessions.wlt:1155,21-1174,2" +] + +(* Symbols created at run time and typed symbols are the same symbols, and functions defined in an earlier call + are not reported as undefined. *) +$localSessionTest @ VerificationTest[ + Module[ { t1, t2 }, + { t1, t2 } = withLocalSessions @ { + localToolText[ "LocalUpperDef[x_] := x + 1; ToExpression[\"localGenSym = 5\"]; LocalUpperDef[1]", "LocalWarnSess" ], + localToolText[ "{LocalUpperDef[2], localGenSym}", "LocalWarnSess" ] + }; + { StringContainsQ[ t2, "{3, 5}" ], StringFreeQ[ t1 <> t2, "Symbol::undefined" ] } + ], + { True, True }, + SameTest -> MatchQ, + TestID -> "Integration-Local-NoUndefinedSymbolWarnings-GH#249@@Tests/EvaluatorSessions.wlt:1178,21-1189,2" +] + +(* The session's context state is applied to this kernel only for the duration of the call. *) +$localSessionTest @ VerificationTest[ + Module[ { before }, + before = { $Context, $ContextPath, $ContextAliases }; + withLocalSessions @ { + localToolText[ "Needs[\"Developer`\" -> \"dv`\"]; 1", "LocalLeakSess" ], + localToolText[ "2", "LocalLeakSess" ] (* continued: the session state includes the alias *) + }; + before === { $Context, $ContextPath, $ContextAliases } + ], + True, + SameTest -> MatchQ, + TestID -> "Integration-Local-ParseContextScopedToCall-GH#249@@Tests/EvaluatorSessions.wlt:1192,21-1204,2" ] (* ::**************************************************************************************************************:: *) @@ -1052,7 +1243,7 @@ $cloudSessionTest @ VerificationTest[ ], { True, True, True, True }, SameTest -> MatchQ, - TestID -> "Integration-CloudSessionDefinitionsSurviveRestart@@Tests/EvaluatorSessions.wlt:1027,21-1056,2" + TestID -> "Integration-CloudSessionDefinitionsSurviveRestart@@Tests/EvaluatorSessions.wlt:1218,21-1247,2" ] (* :!CodeAnalysis::EndBlock:: *) From 4515542fc11657afe7620579aea5d32f3ed90835 Mon Sep 17 00:00:00 2001 From: Rick Hennigan Date: Thu, 8 Oct 2026 16:28:39 +0000 Subject: [PATCH 2/2] Resolve Local parse names with CodeParser and the eval kernel's parser Collect the code's symbol names from CodeParser`CodeConcreteParse instead of a lexical scan, so words in strings and comments are no longer looked up and qualified and relative names stay whole. In the eval kernel, resolve each name with ToExpression, which finds it exactly as the parser would (creating it in $Context if needed) instead of leaving names that do not exist yet to the parse in the controlling kernel. Names that are not valid as written are left out rather than aborting the sync. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0126WqqLgeZKSK3FkAmUxRpG --- Kernel/Tools/WolframLanguageEvaluator.wl | 59 ++++++++++++-------- Tests/EvaluatorSessions.wlt | 70 +++++++++++++----------- 2 files changed, 75 insertions(+), 54 deletions(-) diff --git a/Kernel/Tools/WolframLanguageEvaluator.wl b/Kernel/Tools/WolframLanguageEvaluator.wl index 7c6e6f55..6d7ba02c 100644 --- a/Kernel/Tools/WolframLanguageEvaluator.wl +++ b/Kernel/Tools/WolframLanguageEvaluator.wl @@ -10,6 +10,7 @@ Needs[ "Wolfram`AgentTools`Graphics`" ]; Needs[ "Wolfram`AgentTools`Tools`" ]; Needs[ "Wolfram`Chatbook`" -> "cb`" ]; +Needs[ "CodeParser`" -> "cp`" ]; System`HoldCompleteForm; @@ -1099,11 +1100,11 @@ resumeSessionInKernel // endDefinition; string in this kernel first, and the parse decides which symbol each short name refers to. Left to this kernel's own context state, typed symbols would land in Global` (shared by every session) and names would resolve against this kernel's packages instead of the session's. So before each evaluation - syncParseContext asks the eval kernel which existing symbol each short name in the code resolves to there, - creates those symbols here (this kernel may not have loaded the session's packages), and applies the - session's $Context, $ContextPath, and $ContextAliases to this kernel, scoped to the call by withSession. - Names that resolve to nothing in the eval kernel are then created in the session context, as they would be - there. The in-process methods parse where they evaluate, so this is a no-op for them. *) + syncParseContext asks the eval kernel for the full name of the symbol that each symbol name in the code + refers to there, creates those symbols here (this kernel may not have loaded the session's packages), and + applies the session's $Context, $ContextPath, and $ContextAliases to this kernel, scoped to the call by + withSession. The parse here then finds each name where the eval kernel would. The in-process methods parse + where they evaluate, so this is a no-op for them. *) (* ::**************************************************************************************************************:: *) (* ::Subsubsection::Closed:: *) @@ -1116,7 +1117,7 @@ syncParseContext[ code_String ] /; getEvaluatorMethod[ ] === "Local" := Enclose[ Module[ { state }, state = ConfirmMatch[ (* Inject the literal names for the same reason as in saveSession. *) - With[ { names = symbolNameCandidates @ code }, useEvaluatorKernel @ parseContextInKernel @ names ], + With[ { names = codeSymbolNames @ code }, useEvaluatorKernel @ parseContextInKernel @ names ], KeyValuePattern @ { "$Context" -> _String, "$ContextPath" -> { ___String }, @@ -1153,19 +1154,22 @@ syncParseContextSafe // endDefinition; (* ::**************************************************************************************************************:: *) (* ::Subsubsection::Closed:: *) -(*symbolNameCandidates*) -(* The short names that appear in a code string. A lexical scan is enough because extra candidates (words in - strings and comments, the parts of qualified names) are only looked up, never created unless the eval - kernel already has them. Named-character letters such as \[Alpha] are unescaped first so that they count as - letters. *) -symbolNameCandidates // beginDefinition; - -symbolNameCandidates[ code_String ] := DeleteDuplicates @ StringCases[ - StringReplace[ code, esc: ("\\[" ~~ LetterCharacter.. ~~ "]") :> unescapeLetter @ esc ], - (LetterCharacter | "$") ~~ (WordCharacter | "$")... -]; +(*codeSymbolNames*) +(* The names of the symbols in a code string as written there (short, qualified, or relative such as `f). The + concrete parse also covers code with syntax errors. Named-character letters such as \[Alpha] are unescaped + so that the eval kernel accepts the names (see resolveSymbolName). *) +codeSymbolNames // beginDefinition; + +codeSymbolNames[ code_String ] := ( + (* We need to make sure CodeParser is loaded at runtime, since we might be running from an MX build *) + Needs[ "CodeParser`" -> None ]; + DeleteDuplicates @ StringReplace[ + Cases[ cp`CodeConcreteParse @ code, cp`LeafNode[ Symbol, name_String, _ ] :> name, Infinity ], + esc: ("\\[" ~~ LetterCharacter.. ~~ "]") :> unescapeLetter @ esc + ] +); -symbolNameCandidates // endDefinition; +codeSymbolNames // endDefinition; unescapeLetter // beginDefinition; @@ -1176,9 +1180,8 @@ unescapeLetter // endDefinition; (* ::**************************************************************************************************************:: *) (* ::Subsubsection::Closed:: *) (*parseContextInKernel*) -(* Runs in the eval kernel: its context state, plus the full name of the existing symbol that each given short - name resolves to there. Context[name] performs the kernel's own lookup ($ContextPath, then $Context) without - creating a symbol; names that resolve to nothing are left out. *) +(* Runs in the eval kernel: its context state, plus the full name of the symbol that each given name refers to + there. *) parseContextInKernel // beginDefinition; (* :!CodeAnalysis::BeginBlock:: *) (* :!CodeAnalysis::Disable::SuspiciousSessionSymbol:: *) @@ -1192,11 +1195,21 @@ parseContextInKernel[ names: { ___String } ] := <| parseContextInKernel // endDefinition; +(* The name is looked up exactly as the parser looks it up, which creates the symbol in $Context if it does not + exist yet (as the evaluation would anyway). Names that are not valid symbol names as written (e.g. other + escaped characters) are left out and resolved by the parse in the controlling kernel. *) resolveSymbolName // beginDefinition; -resolveSymbolName[ name_String ] := - With[ { context = Quiet @ Context @ name }, If[ StringQ @ context, context <> name, Nothing ] ]; +resolveSymbolName[ name_String ] /; Internal`SymbolNameQ[ name, True ] := + ToExpression[ name, InputForm, fullSymbolName ]; +resolveSymbolName[ _String ] := Nothing; resolveSymbolName // endDefinition; + +fullSymbolName // beginDefinition; +fullSymbolName // Attributes = { HoldAllComplete }; +fullSymbolName[ symbol_Symbol ] := Context @ Unevaluated @ symbol <> SymbolName @ Unevaluated @ symbol; +fullSymbolName // endDefinition; + (* ::**************************************************************************************************************:: *) (* ::Subsection::Closed:: *) (*Session Cleanup*) diff --git a/Tests/EvaluatorSessions.wlt b/Tests/EvaluatorSessions.wlt index be1580b1..7a953f0b 100644 --- a/Tests/EvaluatorSessions.wlt +++ b/Tests/EvaluatorSessions.wlt @@ -749,19 +749,20 @@ VerificationTest[ (* Under the "Local" method Chatbook parses the code in this kernel but evaluates it in the eval subkernel, so the parse is given the eval kernel's context state and the eval kernel's symbols for the code's names. *) -(* Words in strings and comments are extra candidates, which is harmless; named-character letters are - unescaped so that they belong to a name, while operators such as \[Equal] still separate names. *) +(* Only symbols count (not strings, comments, operators such as \[Equal], or slots), as written: qualified and + relative names stay whole, and named-character letters are unescaped. Code with syntax errors works too. *) VerificationTest[ - Wolfram`AgentTools`Tools`WolframLanguageEvaluator`Private`symbolNameCandidates[ - "f[x_Integer] := \\[Alpha]1 + $v2 + ctx`g + a\\[Equal]b (* note *) + \"word\" + f[2x]" + Wolfram`AgentTools`Tools`WolframLanguageEvaluator`Private`codeSymbolNames[ + "f[x_Integer] := \\[Alpha]1 + $v2 + ctx`g + a\\[Equal]b (* note *) + \"word\" + f[2x + `y] + #s & + h[" ], - { "f", "x", "Integer", "\[Alpha]1", "$v2", "ctx", "g", "a", "Equal", "b", "note", "word" }, + { "f", "x", "Integer", "\[Alpha]1", "$v2", "ctx`g", "a", "b", "`y", "h" }, SameTest -> MatchQ, - TestID -> "SymbolNameCandidates-GH#249@@Tests/EvaluatorSessions.wlt:754,1-761,2" + TestID -> "CodeSymbolNames-GH#249@@Tests/EvaluatorSessions.wlt:754,1-761,2" ] -(* The eval kernel reports its context state and the full name of the existing symbol each name resolves to - ($ContextPath first, then $Context), and does not create symbols for the names that resolve to nothing. *) +(* The eval kernel reports its context state and the full name of the symbol each name refers to there: found on + $ContextPath first, then in $Context, where names that do not exist yet are created (as the parser would); + qualified and relative names are resolved too. Names that are not valid as written are left out. *) VerificationTest[ Module[ { result }, result = Block[ { $Context = "ParseCtxTestA`", $ContextPath = { "ParseCtxTestB`", "System`" } }, @@ -770,9 +771,9 @@ VerificationTest[ ParseCtxTestA`pctOnlyA = 3; { Wolfram`AgentTools`Tools`WolframLanguageEvaluator`Private`parseContextInKernel[ - { "pctShared", "pctOnlyA", "pctMissing", "Plus" } + { "pctShared", "pctOnlyA", "pctNew", "Plus", "`pctRel", "ParseCtxTestB`pctOther", "\\:03b2" } ], - NameQ[ "ParseCtxTestA`pctMissing" ] + NameQ[ "ParseCtxTestA`pctNew" ] } ]; Quiet @ Remove[ "ParseCtxTestA`*", "ParseCtxTestB`*" ]; @@ -783,12 +784,19 @@ VerificationTest[ "$Context" -> "ParseCtxTestA`", "$ContextPath" -> { "ParseCtxTestB`", "System`" }, "$ContextAliases" -> _Association, - "Symbols" -> { "ParseCtxTestB`pctShared", "ParseCtxTestA`pctOnlyA", "System`Plus" } + "Symbols" -> { + "ParseCtxTestB`pctShared", + "ParseCtxTestA`pctOnlyA", + "ParseCtxTestA`pctNew", + "System`Plus", + "ParseCtxTestA`pctRel", + "ParseCtxTestB`pctOther" + } |>, - False + True }, SameTest -> MatchQ, - TestID -> "ParseContextInKernel-ResolvesExistingNames-GH#249@@Tests/EvaluatorSessions.wlt:765,1-792,2" + TestID -> "ParseContextInKernel-ResolvesNames-GH#249@@Tests/EvaluatorSessions.wlt:766,1-800,2" ] (* With the eval kernel stubbed: the symbols it reports are created here although this kernel never loaded @@ -817,7 +825,7 @@ VerificationTest[ ], { "Sessions`PctStubSess`", { "PctStubPkg`", "PctStubPkg`", "Sessions`PctStubSess`" } }, SameTest -> MatchQ, - TestID -> "SyncParseContext-AppliesEvalKernelState-GH#249@@Tests/EvaluatorSessions.wlt:797,1-821,2" + TestID -> "SyncParseContext-AppliesEvalKernelState-GH#249@@Tests/EvaluatorSessions.wlt:805,1-829,2" ] (* In-process methods parse where they evaluate, so this kernel's context state is left alone. *) @@ -830,7 +838,7 @@ VerificationTest[ ] === { $Context, $ContextPath, $ContextAliases }, True, SameTest -> MatchQ, - TestID -> "SyncParseContext-NoOpForInProcess-GH#249@@Tests/EvaluatorSessions.wlt:824,1-834,2" + TestID -> "SyncParseContext-NoOpForInProcess-GH#249@@Tests/EvaluatorSessions.wlt:832,1-842,2" ] (* ::**************************************************************************************************************:: *) @@ -858,7 +866,7 @@ VerificationTest[ StringContainsQ[ extractToolText @ r3, "42" ] ], True, - TestID -> "Integration-SessionIsolation@@Tests/EvaluatorSessions.wlt:843,1-862,2" + TestID -> "Integration-SessionIsolation@@Tests/EvaluatorSessions.wlt:851,1-870,2" ] (* Re-passing the same session ID continues it: definitions persist and line numbers advance. *) @@ -880,7 +888,7 @@ VerificationTest[ StringContainsQ[ text, "6" ] && StringContainsQ[ text, "Out[2]" ] ], True, - TestID -> "Integration-ContinueSamePersistsAndAdvancesLine@@Tests/EvaluatorSessions.wlt:865,1-884,2" + TestID -> "Integration-ContinueSamePersistsAndAdvancesLine@@Tests/EvaluatorSessions.wlt:873,1-892,2" ] (* A session resumes from disk after its in-kernel symbols are gone (simulated server restart). *) @@ -904,7 +912,7 @@ VerificationTest[ StringContainsQ[ extractToolText @ r2, "99" ] ], True, - TestID -> "Integration-RestartResumeFromDisk@@Tests/EvaluatorSessions.wlt:887,1-908,2" + TestID -> "Integration-RestartResumeFromDisk@@Tests/EvaluatorSessions.wlt:895,1-916,2" ] (* Every result echoes the session ID with resume instructions. *) @@ -924,7 +932,7 @@ VerificationTest[ StringContainsQ[ extractToolText @ r, "session=\"AppendSession\"" ] ], True, - TestID -> "Integration-AppendsSessionInfo@@Tests/EvaluatorSessions.wlt:911,1-928,2" + TestID -> "Integration-AppendsSessionInfo@@Tests/EvaluatorSessions.wlt:919,1-936,2" ] (* A fresh session's first evaluation is labeled Out[1]. *) @@ -944,7 +952,7 @@ VerificationTest[ StringContainsQ[ extractToolText @ r, "Out[1]" ] ], True, - TestID -> "Integration-FreshSessionStartsAtLineOne@@Tests/EvaluatorSessions.wlt:931,1-948,2" + TestID -> "Integration-FreshSessionStartsAtLineOne@@Tests/EvaluatorSessions.wlt:939,1-956,2" ] (* Resuming a session continues its line numbering rather than resetting it: A reaches Out[2], B @@ -969,7 +977,7 @@ VerificationTest[ StringContainsQ[ extractToolText @ r, "Out[3]" ] ], True, - TestID -> "Integration-ResumeContinuesLineNumbering@@Tests/EvaluatorSessions.wlt:953,1-973,2" + TestID -> "Integration-ResumeContinuesLineNumbering@@Tests/EvaluatorSessions.wlt:961,1-981,2" ] (* An unknown / expired session ID starts a fresh session reusing that ID and says so. *) @@ -989,7 +997,7 @@ VerificationTest[ StringContainsQ[ text, "NeverSavedXyz" ] && StringContainsQ[ text, "No saved state" ] ], True, - TestID -> "Integration-UnknownIdReusedFresh@@Tests/EvaluatorSessions.wlt:976,1-993,2" + TestID -> "Integration-UnknownIdReusedFresh@@Tests/EvaluatorSessions.wlt:984,1-1001,2" ] (* Context-path changes made inside a session (e.g. by Get) survive continued calls: the continuing @@ -1013,7 +1021,7 @@ VerificationTest[ StringContainsQ[ extractToolText @ r2, "{101, True}" ] ], True, - TestID -> "Integration-ContinuePreservesContextPath@@Tests/EvaluatorSessions.wlt:998,1-1017,2" + TestID -> "Integration-ContinuePreservesContextPath@@Tests/EvaluatorSessions.wlt:1006,1-1025,2" ] (* Resuming a session saved by a different kernel process restores the saved state and warns that @@ -1043,7 +1051,7 @@ VerificationTest[ ], { True, True }, SameTest -> MatchQ, - TestID -> "Integration-ResumeFromPreviousKernelWarns@@Tests/EvaluatorSessions.wlt:1022,1-1047,2" + TestID -> "Integration-ResumeFromPreviousKernelWarns@@Tests/EvaluatorSessions.wlt:1030,1-1055,2" ] (* Switching back to an earlier session within the same kernel process resumes silently: no @@ -1068,7 +1076,7 @@ VerificationTest[ ], { True, False }, SameTest -> MatchQ, - TestID -> "Integration-SameKernelResumeHasNoWarning@@Tests/EvaluatorSessions.wlt:1051,1-1072,2" + TestID -> "Integration-SameKernelResumeHasNoWarning@@Tests/EvaluatorSessions.wlt:1059,1-1080,2" ] (* If the eval kernel loses its in-memory session state while the session is still current (e.g. the @@ -1099,7 +1107,7 @@ VerificationTest[ ], { True, True, False }, SameTest -> MatchQ, - TestID -> "Integration-ContinueFallsBackToFileWhenKernelStateLost@@Tests/EvaluatorSessions.wlt:1077,1-1103,2" + TestID -> "Integration-ContinueFallsBackToFileWhenKernelStateLost@@Tests/EvaluatorSessions.wlt:1085,1-1111,2" ] (* ::**************************************************************************************************************:: *) @@ -1147,7 +1155,7 @@ $localSessionTest @ VerificationTest[ _? (StringContainsQ[ "{localIsoX, \"Sessions`LocalIsoB`\"}" ]) }, SameTest -> MatchQ, - TestID -> "Integration-Local-TypedSymbolsInSessionContext-GH#249@@Tests/EvaluatorSessions.wlt:1140,21-1151,2" + TestID -> "Integration-Local-TypedSymbolsInSessionContext-GH#249@@Tests/EvaluatorSessions.wlt:1148,21-1159,2" ] (* Later calls resolve names through the contexts and aliases the session added: a package loaded only in the @@ -1170,7 +1178,7 @@ $localSessionTest @ VerificationTest[ ], { True, True }, SameTest -> MatchQ, - TestID -> "Integration-Local-SessionContextPathAndAliases-GH#249@@Tests/EvaluatorSessions.wlt:1155,21-1174,2" + TestID -> "Integration-Local-SessionContextPathAndAliases-GH#249@@Tests/EvaluatorSessions.wlt:1163,21-1182,2" ] (* Symbols created at run time and typed symbols are the same symbols, and functions defined in an earlier call @@ -1185,7 +1193,7 @@ $localSessionTest @ VerificationTest[ ], { True, True }, SameTest -> MatchQ, - TestID -> "Integration-Local-NoUndefinedSymbolWarnings-GH#249@@Tests/EvaluatorSessions.wlt:1178,21-1189,2" + TestID -> "Integration-Local-NoUndefinedSymbolWarnings-GH#249@@Tests/EvaluatorSessions.wlt:1186,21-1197,2" ] (* The session's context state is applied to this kernel only for the duration of the call. *) @@ -1200,7 +1208,7 @@ $localSessionTest @ VerificationTest[ ], True, SameTest -> MatchQ, - TestID -> "Integration-Local-ParseContextScopedToCall-GH#249@@Tests/EvaluatorSessions.wlt:1192,21-1204,2" + TestID -> "Integration-Local-ParseContextScopedToCall-GH#249@@Tests/EvaluatorSessions.wlt:1200,21-1212,2" ] (* ::**************************************************************************************************************:: *) @@ -1243,7 +1251,7 @@ $cloudSessionTest @ VerificationTest[ ], { True, True, True, True }, SameTest -> MatchQ, - TestID -> "Integration-CloudSessionDefinitionsSurviveRestart@@Tests/EvaluatorSessions.wlt:1218,21-1247,2" + TestID -> "Integration-CloudSessionDefinitionsSurviveRestart@@Tests/EvaluatorSessions.wlt:1226,21-1255,2" ] (* :!CodeAnalysis::EndBlock:: *)