Skip to content

Python: reset $LASTEXITCODE per command in persistent PowerShell sessions - #8206

Merged
Eduard van Valkenburg (eavanvalkenburg) merged 3 commits into
microsoft:mainfrom
Dev-next-gen:fix/pwsh-session-stale-lastexitcode
Sep 10, 2026
Merged

Python: reset $LASTEXITCODE per command in persistent PowerShell sessions#8206
Eduard van Valkenburg (eavanvalkenburg) merged 3 commits into
microsoft:mainfrom
Dev-next-gen:fix/pwsh-session-stale-lastexitcode

Conversation

@Dev-next-gen

Copy link
Copy Markdown
Contributor

Motivation & Context

I was going through the shell tool on Windows and noticed that LocalShellTool(mode="persistent") reports the exit code of an earlier command for any later command that only runs cmdlets.

ShellSession._build_script derives the return code like this:

"   Invoke-Expression $__af_cmd;"
"   if ($LASTEXITCODE -ne $null) { $__af_rc = $LASTEXITCODE }"
"   elseif (-not $?) { $__af_rc = 1 }"

$LASTEXITCODE is a session-wide automatic variable that PowerShell only updates when a native (external) executable exits — cmdlets leave it untouched. The session is long-lived, so once any command in it has run an external process, $LASTEXITCODE stays set and every subsequent cmdlet-only command takes the first branch and reports that stale value instead of 0.

Practically, the tool tells the agent a command failed when it succeeded, and keeps doing it for the rest of the session until another external command happens to reset the variable.

Here is what I get on main (Windows 11, Windows PowerShell 5.1, Python 3.12.9), driving ShellSession directly:

exit_code=0    cmd=Write-Output 'cmdlet only'
exit_code=3    cmd=cmd /c exit 3
exit_code=3    cmd=Write-Output 'cmdlet only, after a failing native command'
exit_code=3    cmd=Write-Output 'cmdlet only again'

The last two printed their output fine, they just carry rc 3 from the cmd /c exit 3 two commands earlier.

The reason this survived is that the CI matrix is ubuntu-latest in every .github/workflows/python-*.yml, so the PowerShell tests already in test_local_shell_tool.py — the ones behind @pytest.mark.skipif(sys.platform != "win32", ...) — never actually execute anywhere.

Description & Review Guide

  • What are the major changes?

One line in the PowerShell branch of _build_script: clear $LASTEXITCODE before invoking the user command, so a non-null value below can only have come from this command.

" $__af_rc = 0;"
+" $global:LASTEXITCODE = $null;"
" try {"

Plus a Windows-gated regression test alongside the existing PowerShell ones.

  • What is the impact of these changes?

Only the rc computed for cmdlet-only commands in persistent PowerShell mode changes, and only from a wrong value to the right one. Native exit codes, cmdlet errors and stateless mode are untouched. Stateless mode was never affected — it spawns a fresh process per command and takes the rc from the process itself.

Same script after the change:

exit_code=0    cmd=Write-Output 'cmdlet only'
exit_code=3    cmd=cmd /c exit 3
exit_code=0    cmd=Write-Output 'cmdlet only, after a failing native command'
exit_code=0    cmd=Write-Output 'cmdlet only again'
exit_code=0    cmd=cmd /c exit 0
exit_code=42   cmd=cmd /c exit 42
exit_code=1    cmd=Get-Item C:\nope\nope

New test on main:

>           assert succeeding.exit_code == 0, f"inherited stale rc {succeeding.exit_code} from the previous command"
E           AssertionError: inherited stale rc 3 from the previous command
E           assert 3 == 0
E            +  where 3 = ShellResult(stdout='ok', stderr='', exit_code=3, duration_ms=77, truncated=False, timed_out=False).exit_code

And with the fix, the whole packages/tools suite on Windows:

..............................................sss..............s......s. [ 45%]
..................xxx................................................... [ 90%]
...............                                                          [100%]

ruff check packages/tools/ and ruff format --check are clean.

  • What do you want reviewers to focus on?

The $global: scope qualifier is load-bearing and I want a second pair of eyes on it. The engine writes $LASTEXITCODE into the global scope, so assigning the unqualified name inside the & { ... } block creates a local that shadows it, and real native exit codes then become invisible. I checked both spellings in a plain shell before picking one:

# with an unqualified `$LASTEXITCODE = $null` inside the block
inside after local reset: []
inside after cmdlet:      []
inside after native:      []      <- exit code lost

# with `$global:LASTEXITCODE = $null`
A after global reset: []
B after cmdlet:       []
C after native:       [5]         <- correct

One thing I deliberately left alone: dotnet/src/Microsoft.Agents.AI.Tools.Shell/ShellSession.cs has the same two lines around line 592 and looks like it has the same behaviour, but I have not built or run the .NET side so I did not want to touch it on a hunch. Happy to open a separate .NET PR if you confirm it, or it may be simpler for someone with that toolchain set up to port the one-liner.

Related Issue

Fixes #8205

Contribution Checklist

  • The code builds clean without any errors or warnings
  • All unit tests pass, and I have added new tests where possible
  • The PR follows the Contribution Guidelines
  • This PR is linked to an issue and there is no other open PR for this issue (see Related Issue above).
  • This is not a breaking change.

AI tools used

…ions

$LASTEXITCODE is a session-wide automatic variable that only native
(external) processes update, so in a long-lived session it stays set
after such a command. Every later cmdlet-only command then took the
$LASTEXITCODE branch in the sentinel script and reported that stale
value instead of 0.

Clear it in the global scope before invoking the user command so a
non-null value can only have come from this command. The global scope
qualifier matters: assigning the unqualified name inside the script
block shadows the global the engine writes to, which would hide real
native exit codes.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Resetting $global:LASTEXITCODE breaks observable state across persistent commands.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Fixes stale PowerShell exit codes in persistent Python shell sessions.

Changes:

  • Resets $LASTEXITCODE before each command.
  • Adds a Windows regression test.
File summaries
File Description
python/packages/tools/agent_framework_tools/shell/_session.py Adjusts PowerShell exit-code handling.
python/packages/tools/tests/test_local_shell_tool.py Tests stale exit-code prevention.
Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 2
  • Review effort level: Balanced

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment thread python/packages/tools/agent_framework_tools/shell/_session.py Outdated
Comment thread python/packages/tools/agent_framework_tools/shell/_session.py Outdated
@Dev-next-gen

Copy link
Copy Markdown
Contributor Author

@microsoft-github-policy-service agree

Comment thread python/packages/tools/agent_framework_tools/shell/_session.py Outdated
Review feedback: clearing $global:LASTEXITCODE before the user's command
destroys state persistent mode exists to carry. After `cmd /c exit 3`, a
following `Write-Output $LASTEXITCODE` printed nothing instead of 3.

Snapshot the value before the command and compare afterwards instead. The
variable is never assigned, so the user's own commands still read it, and a
value that differs can only have been written by this command. $? is read on
the statement right after Invoke-Expression, since anything else clobbers it.

The regression test now also asserts the readback prints 3 while its own
exit code is 0, which fails against the previous approach.
Merged via the queue into microsoft:main with commit c457aca Sep 10, 2026
40 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Usage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

.NET: Python: [Bug]: persistent PowerShell shell session reports a stale exit code for cmdlet-only commands

4 participants