Skip to content

Commit 16de6a1

Browse files
committed
cookbook(python): add PyInstaller frozen build and error recovery hooks recipes
1 parent 8395dce commit 16de6a1

4 files changed

Lines changed: 547 additions & 0 deletions

File tree

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# Error Recovery Hooks
2+
3+
Keep the LLM investigating when tools fail instead of giving up with a partial result.
4+
5+
## Problem
6+
7+
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.
8+
9+
## Solution
10+
11+
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.
12+
13+
```python
14+
from enum import Enum
15+
16+
17+
class ToolResultCategory(str, Enum):
18+
SHELL_ERROR = "shell_error"
19+
PERMISSION_DENIED = "permission_denied"
20+
NORMAL = "normal"
21+
22+
23+
class SDKErrorCategory(str, Enum):
24+
CLIENT_ERROR = "client_error" # 4xx — not retryable
25+
TRANSIENT = "transient" # 5xx / timeout
26+
NON_RECOVERABLE = "non_recoverable"
27+
28+
29+
# Phrases that signal permission issues in tool output
30+
PERMISSION_DENIAL_PHRASES = [
31+
"permission denied",
32+
"access denied",
33+
"not permitted",
34+
"operation not allowed",
35+
"eacces",
36+
"eperm",
37+
"403 forbidden",
38+
]
39+
40+
SHELL_ERROR_PHRASES = [
41+
"command not found",
42+
"no such file or directory",
43+
"exit code",
44+
"errno",
45+
"traceback",
46+
]
47+
48+
CONTINUATION_MESSAGES = {
49+
ToolResultCategory.SHELL_ERROR: (
50+
"\n\n[SYSTEM NOTE: This command encountered an error. "
51+
"This does NOT mean you should stop. Retry with different "
52+
"arguments, try a different tool, or move on.]"
53+
),
54+
ToolResultCategory.PERMISSION_DENIED: (
55+
"\n\n[SYSTEM NOTE: Permission was denied for this specific "
56+
"action. Continue using alternative approaches.]"
57+
),
58+
}
59+
60+
61+
def classify_tool_result(tool_name: str, result_text: str) -> ToolResultCategory:
62+
result_lower = result_text.lower()
63+
if any(phrase in result_lower for phrase in PERMISSION_DENIAL_PHRASES):
64+
return ToolResultCategory.PERMISSION_DENIED
65+
if any(phrase in result_lower for phrase in SHELL_ERROR_PHRASES):
66+
return ToolResultCategory.SHELL_ERROR
67+
return ToolResultCategory.NORMAL
68+
69+
70+
def classify_sdk_error(error_msg: str, recoverable: bool) -> SDKErrorCategory:
71+
error_lower = error_msg.lower()
72+
if any(kw in error_lower for kw in ("timeout", "503", "502", "429", "retry")):
73+
return SDKErrorCategory.TRANSIENT
74+
if any(kw in error_lower for kw in ("401", "403", "404", "400", "422")):
75+
return SDKErrorCategory.CLIENT_ERROR
76+
return SDKErrorCategory.TRANSIENT if recoverable else SDKErrorCategory.NON_RECOVERABLE
77+
```
78+
79+
## Hook Registration
80+
81+
Wire the classifiers into the SDK's hook system:
82+
83+
```python
84+
def on_post_tool_use(input_data, env):
85+
"""Append continuation hints to failed tool results."""
86+
tool_name = input_data.get("toolName", "")
87+
result = str(input_data.get("toolResult", ""))
88+
category = classify_tool_result(tool_name, result)
89+
if category in CONTINUATION_MESSAGES:
90+
return {"toolResult": result + CONTINUATION_MESSAGES[category]}
91+
return None
92+
93+
94+
def on_error_occurred(input_data, env):
95+
"""Retry transient errors, skip non-recoverable ones gracefully."""
96+
error_msg = input_data.get("error", "")
97+
recoverable = input_data.get("recoverable", False)
98+
category = classify_sdk_error(error_msg, recoverable)
99+
if category == SDKErrorCategory.TRANSIENT:
100+
return {"errorHandling": "retry", "retryCount": 2}
101+
return {
102+
"errorHandling": "skip",
103+
"userNotification": "Error occurred — continuing investigation.",
104+
}
105+
```
106+
107+
## Tips
108+
109+
- **Tune the phrase lists** for your domain — add patterns from your actual tool output.
110+
- **Log classified categories** so you can track how often each failure mode fires and whether the LLM actually recovers.
111+
- **Cap continuation depth** — if the same tool fails 3+ times in a row, let the LLM give up rather than looping.
112+
- The `SYSTEM NOTE` framing works well because the LLM treats it as authoritative instruction rather than user commentary.
113+
114+
## Runnable Example
115+
116+
See [`recipe/error_recovery_hooks.py`](recipe/error_recovery_hooks.py) for a complete working example.
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# Deploying Copilot SDK Apps with PyInstaller
2+
3+
Package a Copilot SDK application into a standalone executable using PyInstaller (or Nuitka).
4+
5+
## Problem
6+
7+
When you freeze a Python SDK application with PyInstaller, three things break:
8+
9+
1. **CLI binary resolution** — The SDK locates its CLI via `__file__`, which points inside the PYZ archive in a frozen build.
10+
2. **SSL certificates** — On macOS, the frozen app can't find system CA certs, so the CLI subprocess fails TLS handshakes.
11+
3. **Execute permissions** — The bundled CLI binary may lose its `+x` bit when extracted from the archive.
12+
13+
## Solution
14+
15+
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.
16+
17+
```python
18+
"""Frozen-build compatibility for Copilot SDK applications."""
19+
import os, sys
20+
from pathlib import Path
21+
from copilot import CopilotClient, SubprocessConfig
22+
23+
24+
def resolve_cli_path() -> str | None:
25+
"""Find the Copilot CLI binary in a frozen build."""
26+
candidates = []
27+
binary = "copilot.exe" if sys.platform == "win32" else "copilot"
28+
29+
# 1. SDK's normal resolution
30+
try:
31+
import copilot as pkg
32+
candidates.append(Path(pkg.__file__).parent / "bin" / binary)
33+
except Exception:
34+
pass
35+
36+
# 2. PyInstaller _MEIPASS fallback
37+
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
38+
meipass = Path(sys._MEIPASS)
39+
candidates.append(meipass / "copilot" / "bin" / binary)
40+
candidates.append(meipass.parent / "copilot" / "bin" / binary)
41+
42+
for c in candidates:
43+
if c.exists():
44+
if sys.platform != "win32" and not os.access(str(c), os.X_OK):
45+
os.chmod(str(c), c.stat().st_mode | 0o755)
46+
return str(c)
47+
return None
48+
49+
50+
def ensure_ssl_certs():
51+
"""Set SSL env vars for the CLI subprocess (macOS frozen builds)."""
52+
if os.environ.get("SSL_CERT_FILE"):
53+
return
54+
try:
55+
import certifi
56+
ca = certifi.where()
57+
if Path(ca).is_file():
58+
os.environ["SSL_CERT_FILE"] = ca
59+
os.environ["REQUESTS_CA_BUNDLE"] = ca
60+
os.environ.setdefault("NODE_EXTRA_CA_CERTS", ca)
61+
except ImportError:
62+
pass # CLI will use platform defaults
63+
64+
65+
async def create_frozen_client():
66+
"""Create a CopilotClient that works in both normal and frozen builds."""
67+
ensure_ssl_certs()
68+
kwargs = {"log_level": "info", "use_stdio": True}
69+
if getattr(sys, "frozen", False):
70+
cli = resolve_cli_path()
71+
if cli:
72+
kwargs["cli_path"] = cli
73+
client = CopilotClient(SubprocessConfig(**kwargs), auto_start=True)
74+
await client.start()
75+
return client
76+
```
77+
78+
## PyInstaller Spec
79+
80+
Include the SDK's binary directory in your `.spec` file so PyInstaller bundles it:
81+
82+
```python
83+
from PyInstaller.utils.hooks import collect_data_files
84+
85+
datas += collect_data_files('copilot', include_py_files=False)
86+
```
87+
88+
## Tips
89+
90+
- **Test the frozen build on a clean machine**`_MEIPASS` extraction behaves differently than your dev environment.
91+
- **Pin `certifi`** in your requirements so the CA bundle is always available.
92+
- **Nuitka** uses a different extraction model (`--include-package-data=copilot`), but the same `resolve_cli_path` logic works.
93+
94+
## Runnable Example
95+
96+
See [`recipe/pyinstaller_frozen_build.py`](recipe/pyinstaller_frozen_build.py) for a complete working example.

0 commit comments

Comments
 (0)