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
2 changes: 2 additions & 0 deletions cookbook/copilot-sdk/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ This folder hosts short, practical recipes for using the GitHub Copilot SDK with
## Recipes

- [Error Handling](error-handling.md): Handle errors gracefully including connection failures, timeouts, and cleanup.
- [Error Recovery Hooks](error-recovery-hooks.md): Classify tool failures and nudge the LLM to keep investigating instead of giving up.
- [Multiple Sessions](multiple-sessions.md): Manage multiple independent conversations simultaneously.
- [Managing Local Files](managing-local-files.md): Organize files by metadata using AI-powered grouping strategies.
- [PR Visualization](pr-visualization.md): Generate interactive PR age charts using GitHub MCP Server.
- [Persisting Sessions](persisting-sessions.md): Save and resume sessions across restarts.
- [PyInstaller Frozen Build](pyinstaller-frozen-build.md): Package a Copilot SDK application into a standalone executable with PyInstaller.

## Contributing

Expand Down
116 changes: 116 additions & 0 deletions cookbook/copilot-sdk/python/error-recovery-hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Error Recovery Hooks

Keep the LLM investigating when tools fail instead of giving up with a partial result.

## Problem

When a shell command returns an error or a file operation hits a permission denial, the LLM tends to stop and apologize rather than trying a different approach. This produces incomplete results in agentic workflows where resilience matters.

## Solution

Use the SDK's hooks system (`on_post_tool_use`, `on_error_occurred`) to classify tool results by category and append continuation instructions that nudge the LLM to keep going.

```python
from enum import Enum


class ToolResultCategory(str, Enum):
SHELL_ERROR = "shell_error"
PERMISSION_DENIED = "permission_denied"
NORMAL = "normal"


class SDKErrorCategory(str, Enum):
CLIENT_ERROR = "client_error" # 4xx — not retryable
TRANSIENT = "transient" # 5xx / timeout
NON_RECOVERABLE = "non_recoverable"


# Phrases that signal permission issues in tool output
PERMISSION_DENIAL_PHRASES = [
"permission denied",
"access denied",
"not permitted",
"operation not allowed",
"eacces",
"eperm",
"403 forbidden",
]

SHELL_ERROR_PHRASES = [
"command not found",
"no such file or directory",
"exit code",
"errno",
"traceback",
]

CONTINUATION_MESSAGES = {
ToolResultCategory.SHELL_ERROR: (
"\n\n[SYSTEM NOTE: This command encountered an error. "
"This does NOT mean you should stop. Retry with different "
"arguments, try a different tool, or move on.]"
),
ToolResultCategory.PERMISSION_DENIED: (
"\n\n[SYSTEM NOTE: Permission was denied for this specific "
"action. Continue using alternative approaches.]"
),
}


def classify_tool_result(tool_name: str, result_text: str) -> ToolResultCategory:
result_lower = result_text.lower()
if any(phrase in result_lower for phrase in PERMISSION_DENIAL_PHRASES):
return ToolResultCategory.PERMISSION_DENIED
if any(phrase in result_lower for phrase in SHELL_ERROR_PHRASES):
return ToolResultCategory.SHELL_ERROR
return ToolResultCategory.NORMAL


def classify_sdk_error(error_msg: str, recoverable: bool) -> SDKErrorCategory:
error_lower = error_msg.lower()
if any(kw in error_lower for kw in ("timeout", "503", "502", "429", "retry")):
return SDKErrorCategory.TRANSIENT
if any(kw in error_lower for kw in ("401", "403", "404", "400", "422")):
return SDKErrorCategory.CLIENT_ERROR
return SDKErrorCategory.TRANSIENT if recoverable else SDKErrorCategory.NON_RECOVERABLE
```

## Hook Registration

Wire the classifiers into the SDK's hook system:

```python
def on_post_tool_use(input_data, env):
"""Append continuation hints to failed tool results."""
tool_name = input_data.get("toolName", "")
result = str(input_data.get("toolResult", ""))
category = classify_tool_result(tool_name, result)
if category in CONTINUATION_MESSAGES:
return {"toolResult": result + CONTINUATION_MESSAGES[category]}
return None


def on_error_occurred(input_data, env):
"""Retry transient errors, skip non-recoverable ones gracefully."""
error_msg = input_data.get("error", "")
recoverable = input_data.get("recoverable", False)
category = classify_sdk_error(error_msg, recoverable)
if category == SDKErrorCategory.TRANSIENT:
return {"errorHandling": "retry", "retryCount": 2}
return {
"errorHandling": "skip",
"userNotification": "Error occurred — continuing investigation.",
}
```

## Tips

- **Tune the phrase lists** for your domain — add patterns from your actual tool output.
- **Log classified categories** so you can track how often each failure mode fires and whether the LLM actually recovers.
- **Cap continuation depth** — if the same tool fails 3+ times in a row, let the LLM give up rather than looping.
- The `SYSTEM NOTE` framing works well because the LLM treats it as authoritative instruction rather than user commentary.

## Runnable Example

See [`recipe/error_recovery_hooks.py`](recipe/error_recovery_hooks.py) for a complete working example.
96 changes: 96 additions & 0 deletions cookbook/copilot-sdk/python/pyinstaller-frozen-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Deploying Copilot SDK Apps with PyInstaller

Package a Copilot SDK application into a standalone executable using PyInstaller (or Nuitka).

## Problem

When you freeze a Python SDK application with PyInstaller, three things break:

1. **CLI binary resolution** — The SDK locates its CLI via `__file__`, which points inside the PYZ archive in a frozen build.
2. **SSL certificates** — On macOS, the frozen app can't find system CA certs, so the CLI subprocess fails TLS handshakes.
3. **Execute permissions** — The bundled CLI binary may lose its `+x` bit when extracted from the archive.

## Solution

Resolve the CLI path by searching both the SDK's normal location and PyInstaller's `_MEIPASS` temp directory. Fix SSL by injecting `certifi`'s CA bundle into the environment. Restore execute permissions on Unix before launching.

```python
"""Frozen-build compatibility for Copilot SDK applications."""
import os, sys
from pathlib import Path
from copilot import CopilotClient, SubprocessConfig


def resolve_cli_path() -> str | None:
"""Find the Copilot CLI binary in a frozen build."""
candidates = []
binary = "copilot.exe" if sys.platform == "win32" else "copilot"

# 1. SDK's normal resolution
try:
import copilot as pkg
candidates.append(Path(pkg.__file__).parent / "bin" / binary)
except Exception:
pass

# 2. PyInstaller _MEIPASS fallback
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
meipass = Path(sys._MEIPASS)
candidates.append(meipass / "copilot" / "bin" / binary)
candidates.append(meipass.parent / "copilot" / "bin" / binary)

for c in candidates:
if c.exists():
if sys.platform != "win32" and not os.access(str(c), os.X_OK):
os.chmod(str(c), c.stat().st_mode | 0o755)
return str(c)
return None


def ensure_ssl_certs():
"""Set SSL env vars for the CLI subprocess (macOS frozen builds)."""
if os.environ.get("SSL_CERT_FILE"):
return
try:
import certifi
ca = certifi.where()
if Path(ca).is_file():
os.environ["SSL_CERT_FILE"] = ca
os.environ["REQUESTS_CA_BUNDLE"] = ca
os.environ.setdefault("NODE_EXTRA_CA_CERTS", ca)
except ImportError:
pass # CLI will use platform defaults


async def create_frozen_client():
"""Create a CopilotClient that works in both normal and frozen builds."""
ensure_ssl_certs()
kwargs = {"log_level": "info", "use_stdio": True}
if getattr(sys, "frozen", False):
cli = resolve_cli_path()
if cli:
kwargs["cli_path"] = cli
client = CopilotClient(SubprocessConfig(**kwargs), auto_start=True)
await client.start()
return client
```

## PyInstaller Spec

Include the SDK's binary directory in your `.spec` file so PyInstaller bundles it:

```python
from PyInstaller.utils.hooks import collect_data_files

data += collect_data_files('copilot', include_py_files=False)
```

## Tips

- **Test the frozen build on a clean machine** — `_MEIPASS` extraction behaves differently than your dev environment.
- **Pin `certifi`** in your requirements so the CA bundle is always available.
- **Nuitka** uses a different extraction model (`--include-package-data=copilot`), but the same `resolve_cli_path` logic works.

## Runnable Example

See [`recipe/pyinstaller_frozen_build.py`](recipe/pyinstaller_frozen_build.py) for a complete working example.
16 changes: 9 additions & 7 deletions cookbook/copilot-sdk/python/recipe/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,15 @@ python <filename>.py

### Available Recipes

| Recipe | Command | Description |
| -------------------- | -------------------------------- | ------------------------------------------ |
| Error Handling | `python error_handling.py` | Demonstrates error handling patterns |
| Multiple Sessions | `python multiple_sessions.py` | Manages multiple independent conversations |
| Managing Local Files | `python managing_local_files.py` | Organizes files using AI grouping |
| PR Visualization | `python pr_visualization.py` | Generates PR age charts |
| Persisting Sessions | `python persisting_sessions.py` | Save and resume sessions across restarts |
| Recipe | Command | Description |
| -------------------- | ------------------------------------ | -------------------------------------------------- |
| Error Handling | `python error_handling.py` | Demonstrates error handling patterns |
| Error Recovery Hooks | `python error_recovery_hooks.py` | Classifies tool failures and retries automatically |
| Multiple Sessions | `python multiple_sessions.py` | Manages multiple independent conversations |
| Managing Local Files | `python managing_local_files.py` | Organizes files using AI grouping |
| PR Visualization | `python pr_visualization.py` | Generates PR age charts |
| Persisting Sessions | `python persisting_sessions.py` | Save and resume sessions across restarts |
| PyInstaller Build | `python pyinstaller_frozen_build.py` | Packages SDK apps into frozen executables |

### Examples with Arguments

Expand Down
Loading
Loading