Skip to content

Python: [Bug]: Persistent PowerShell LocalShellTool reports exit_code 0 when a native command fails with the same code twice #9657

Description

Observed Behavior

With mode="persistent" and PowerShell (the default shell on Windows), LocalShellTool reports exit_code=0 for a native command that failed, whenever it exits with the same code as the previous native command in the session. An agent that runs a failing test command, edits the code and reruns it with the same failure (exit 1 both times) is told that the second run succeeded. The same happens with repeated git errors (128) or any repeated exit N.

Since #8206, ShellSession._build_script (python/packages/tools/agent_framework_tools/shell/_session.py) works out the exit code like this:

$__af_last = $LASTEXITCODE;
...
Invoke-Expression $__af_cmd | Out-Default;
$__af_ok = $?;
if ($LASTEXITCODE -ne $__af_last) { $__af_rc = $LASTEXITCODE }
elseif (-not $__af_ok) { $__af_rc = 1 }

When the native command exits with the code that is already in $LASTEXITCODE, the value doesn't change, so the first branch doesn't run. The elseif doesn't run either, because $? reflects Invoke-Expression, which succeeded, rather than the native command inside it. That is the same on PowerShell 7.6.6 and Windows PowerShell 5.1:

cmd /c exit 3; $?                                  -> False
Invoke-Expression 'cmd /c exit 3'; $?              -> True
Invoke-Expression 'cmd /c exit 3' | Out-Default; $? -> True

So $__af_rc stays 0. The #8206 discussion mentioned this same-code case and expected the elseif to still report 1, but in practice it reports success.

Expected Behavior

Each failing run reports its own exit code: 1, 1, 1 and 128, 128 in the reproduction below. Commands that don't run a native process keep reporting 0, or 1 on a cmdlet error, and $LASTEXITCODE stays readable by the user's commands.

Steps to Reproduce

  1. Create a LocalShellTool(mode="persistent") on Windows, where the default shell is PowerShell.
  2. Run a native command that exits with a non-zero code, for example python -c "import sys; sys.exit(1)". It reports exit_code=1.
  3. Run the same command again. It reports exit_code=0, although it failed with 1 again.

Minimal Reproduction

import asyncio
import sys

from agent_framework.tools import LocalShellTool

FAILING_PYTHON = f"& '{sys.executable}' -c 'import sys; sys.exit(1)'"
COMMANDS = [FAILING_PYTHON, FAILING_PYTHON, FAILING_PYTHON, "git rev-parse --git-dir", "git rev-parse --git-dir"]


async def main() -> None:
    # Default shell on Windows; run from a directory that is not a git repository.
    async with LocalShellTool(mode="persistent") as tool:
        for command in COMMANDS:
            result = await tool.run(command)
            label = "python -c 'import sys; sys.exit(1)'" if command == FAILING_PYTHON else command
            print(f"exit_code={result.exit_code:<4} <- {label}")


asyncio.run(main())

Output on main (Windows 11, PowerShell 7.6.6):

exit_code=1    <- python -c 'import sys; sys.exit(1)'
exit_code=0    <- python -c 'import sys; sys.exit(1)'
exit_code=0    <- python -c 'import sys; sys.exit(1)'
exit_code=128  <- git rev-parse --git-dir
exit_code=0    <- git rev-parse --git-dir

I get the same output with Windows PowerShell 5.1 (AGENT_FRAMEWORK_SHELL="powershell -NoLogo -NoProfile -NonInteractive -Command -").

Error Messages and Stack Traces

No response

Package Versions

agent-framework-tools: 1.0.0b261022, agent-framework-core: 1.23.0 (source checkout of main at 4982e4c)

Python Version

Python 3.13

Operating System

Windows

Regression

Yes

Additional Context

Acknowledgements

  • I searched existing issues and did not find a duplicate.
  • I personally verified this behavior and the reproduction details are authentic.
  • I will wait for explicit maintainer agreement before starting implementation of a non-trivial change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs-maintainer-triageUsage: [Issues], Target: all issues that the automated triage flow failed to processpythonUsage: [Issues, PRs], Target: PythontriageUsage: [Issues], Target: All issues that still need to be triaged

    Type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions