diff --git a/cookbook/python/error-handling.md b/cookbook/python/error-handling.md index 63d1488d..e44f6ba7 100644 --- a/cookbook/python/error-handling.md +++ b/cookbook/python/error-handling.md @@ -16,41 +16,46 @@ You need to handle various error conditions like connection failures, timeouts, ## Basic try-except ```python +import asyncio from copilot import CopilotClient -client = CopilotClient() - -try: - client.start() - session = client.create_session(model="gpt-5") +async def main(): + client = CopilotClient() response = None + def handle_message(event): nonlocal response - if event["type"] == "assistant.message": - response = event["data"]["content"] + if event.type == "assistant.message": + response = event.data.content - session.on(handle_message) - session.send(prompt="Hello!") - session.wait_for_idle() + try: + await client.start() + session = await client.create_session() + session.on(handle_message) - if response: - print(response) + await session.send_and_wait({"prompt": "Hello!"}) - session.destroy() -except Exception as e: - print(f"Error: {e}") -finally: - client.stop() + if response: + print(response) + + await session.destroy() + + except Exception as e: + print(f"Error: {e}") + + finally: + await client.stop() + + +asyncio.run(main()) ``` ## Handling specific error types ```python -import subprocess - try: - client.start() + await client.start() except FileNotFoundError: print("Copilot CLI not found. Please install it first.") except ConnectionError: @@ -61,90 +66,67 @@ except Exception as e: ## Timeout handling -```python -import signal -from contextlib import contextmanager +The SDK's `send_and_wait()` method accepts a timeout parameter: -@contextmanager -def timeout(seconds): - def timeout_handler(signum, frame): - raise TimeoutError("Request timed out") - - old_handler = signal.signal(signal.SIGALRM, timeout_handler) - signal.alarm(seconds) - try: - yield - finally: - signal.alarm(0) - signal.signal(signal.SIGALRM, old_handler) +```python +import asyncio -session = client.create_session(model="gpt-5") +session = await client.create_session() try: - session.send(prompt="Complex question...") - # Wait with timeout (30 seconds) - with timeout(30): - session.wait_for_idle() - + await session.send_and_wait({"prompt": "Complex question..."}, timeout=30.0) print("Response received") -except TimeoutError: +except asyncio.TimeoutError: print("Request timed out") ``` ## Aborting a request ```python -import threading +import asyncio -session = client.create_session(model="gpt-5") +session = await client.create_session() -# Start a request -session.send(prompt="Write a very long story...") -# Abort it after some condition -def abort_later(): - import time - time.sleep(5) - session.abort() +async def abort_after_delay(): + await asyncio.sleep(5) + await session.abort() print("Request aborted") -threading.Thread(target=abort_later).start() + +# Start the abort task +abort_task = asyncio.create_task(abort_after_delay()) + +# Send a long request +await session.send({"prompt": "Write a very long story..."}) ``` ## Graceful shutdown ```python +import asyncio import signal -import sys -def signal_handler(sig, frame): +client = CopilotClient() + + +async def shutdown(): print("\nShutting down...") - errors = client.stop() + errors = await client.stop() if errors: print(f"Cleanup errors: {errors}") - sys.exit(0) - -signal.signal(signal.SIGINT, signal_handler) -``` - -## Context manager for automatic cleanup - -```python -from copilot import CopilotClient - -with CopilotClient() as client: - client.start() - session = client.create_session(model="gpt-5") - # ... do work ... - # client.stop() is automatically called when exiting context +# Handle Ctrl+C gracefully +loop = asyncio.get_event_loop() +loop.add_signal_handler(signal.SIGINT, lambda: asyncio.create_task(shutdown())) ``` ## Best practices -1. **Always clean up**: Use try-finally or context managers to ensure `stop()` is called +1. **Always clean up**: Use try-finally to ensure `await client.stop()` is called 2. **Handle connection errors**: The CLI might not be installed or running -3. **Set appropriate timeouts**: Long-running requests should have timeouts +3. **Use timeouts**: Pass timeout to `send_and_wait()` for long-running requests 4. **Log errors**: Capture error details for debugging +5. **Use async patterns**: The SDK is fully async - use `asyncio.run()` as entry point diff --git a/cookbook/python/managing-local-files.md b/cookbook/python/managing-local-files.md index a085c538..d314d67c 100644 --- a/cookbook/python/managing-local-files.md +++ b/cookbook/python/managing-local-files.md @@ -16,31 +16,35 @@ You have a folder with many files and want to organize them into subfolders base ## Example code ```python -from copilot import CopilotClient +import asyncio import os +from copilot import CopilotClient -# Create and start client -client = CopilotClient() -client.start() -# Create session -session = client.create_session(model="gpt-5") +async def main(): + # Create and start client + client = CopilotClient() + await client.start() -# Event handler -def handle_event(event): - if event["type"] == "assistant.message": - print(f"\nCopilot: {event['data']['content']}") - elif event["type"] == "tool.execution_start": - print(f" → Running: {event['data']['toolName']}") - elif event["type"] == "tool.execution_complete": - print(f" āœ“ Completed: {event['data']['toolCallId']}") + # Create session + session = await client.create_session() -session.on(handle_event) + # Event handler + def handle_event(event): + if event.type == "assistant.message": + print(f"\nCopilot: {event.data.content}") + elif event.type == "tool.execution_start": + print(f" → Running: {event.data.tool_name}") + elif event.type == "tool.execution_complete": + print(f" āœ“ Completed: {event.data.tool_call_id}") -# Ask Copilot to organize files -target_folder = os.path.expanduser("~/Downloads") + session.on(handle_event) -session.send(prompt=f""" + # Ask Copilot to organize files + target_folder = os.path.expanduser("~/Downloads") + + await session.send_and_wait({ + "prompt": f""" Analyze the files in "{target_folder}" and organize them into subfolders. 1. First, list all files and their metadata @@ -49,11 +53,14 @@ Analyze the files in "{target_folder}" and organize them into subfolders. 4. Move each file to its appropriate subfolder Please confirm before moving any files. -""") +""" + }) + + await session.destroy() + await client.stop() -session.wait_for_idle() -client.stop() +asyncio.run(main()) ``` ## Grouping strategies @@ -90,10 +97,12 @@ client.stop() For safety, you can ask Copilot to only preview changes: ```python -session.send(prompt=f""" +await session.send_and_wait({ + "prompt": f""" Analyze files in "{target_folder}" and show me how you would organize them by file type. DO NOT move any files - just show me the plan. -""") +""" +}) ``` ## Custom grouping with AI analysis @@ -101,7 +110,8 @@ by file type. DO NOT move any files - just show me the plan. Let Copilot determine the best grouping based on file content: ```python -session.send(prompt=f""" +await session.send_and_wait({ + "prompt": f""" Look at the files in "{target_folder}" and suggest a logical organization. Consider: - File names and what they might contain @@ -109,7 +119,8 @@ Consider: - Date patterns that might indicate projects or events Propose folder names that are descriptive and useful. -""") +""" +}) ``` ## Safety considerations diff --git a/cookbook/python/multiple-sessions.md b/cookbook/python/multiple-sessions.md index 6e0cff41..c4b7e2dd 100644 --- a/cookbook/python/multiple-sessions.md +++ b/cookbook/python/multiple-sessions.md @@ -16,31 +16,37 @@ You need to run multiple conversations in parallel, each with its own context an ## Python ```python +import asyncio from copilot import CopilotClient -client = CopilotClient() -client.start() - -# Create multiple independent sessions -session1 = client.create_session(model="gpt-5") -session2 = client.create_session(model="gpt-5") -session3 = client.create_session(model="claude-sonnet-4.5") - -# Each session maintains its own conversation history -session1.send(prompt="You are helping with a Python project") -session2.send(prompt="You are helping with a TypeScript project") -session3.send(prompt="You are helping with a Go project") - -# Follow-up messages stay in their respective contexts -session1.send(prompt="How do I create a virtual environment?") -session2.send(prompt="How do I set up tsconfig?") -session3.send(prompt="How do I initialize a module?") - -# Clean up all sessions -session1.destroy() -session2.destroy() -session3.destroy() -client.stop() + +async def main(): + client = CopilotClient() + await client.start() + + # Create multiple independent sessions + session1 = await client.create_session() + session2 = await client.create_session() + session3 = await client.create_session({"model": "claude-sonnet-4"}) + + # Each session maintains its own conversation history + await session1.send({"prompt": "You are helping with a Python project"}) + await session2.send({"prompt": "You are helping with a TypeScript project"}) + await session3.send({"prompt": "You are helping with a Go project"}) + + # Follow-up messages stay in their respective contexts + await session1.send_and_wait({"prompt": "How do I create a virtual environment?"}) + await session2.send_and_wait({"prompt": "How do I set up tsconfig?"}) + await session3.send_and_wait({"prompt": "How do I initialize a module?"}) + + # Clean up all sessions + await session1.destroy() + await session2.destroy() + await session3.destroy() + await client.stop() + + +asyncio.run(main()) ``` ## Custom session IDs @@ -48,10 +54,9 @@ client.stop() Use custom IDs for easier tracking: ```python -session = client.create_session( - session_id="user-123-chat", - model="gpt-5" -) +session = await client.create_session({ + "session_id": "user-123-chat", +}) print(session.session_id) # "user-123-chat" ``` @@ -59,16 +64,18 @@ print(session.session_id) # "user-123-chat" ## Listing sessions ```python -sessions = client.list_sessions() +# List all available sessions +sessions = await client.list_sessions() for session_info in sessions: - print(f"Session: {session_info['sessionId']}") + print(f"Session: {session_info['session_id']}") + print(f" Modified: {session_info['modified_time']}") ``` ## Deleting sessions ```python -# Delete a specific session -client.delete_session("user-123-chat") +# Delete a specific session permanently +await client.delete_session("user-123-chat") ``` ## Use cases diff --git a/cookbook/python/persisting-sessions.md b/cookbook/python/persisting-sessions.md index e0dfb797..b3ef91f7 100644 --- a/cookbook/python/persisting-sessions.md +++ b/cookbook/python/persisting-sessions.md @@ -16,68 +16,86 @@ You want users to be able to continue a conversation even after closing and reop ### Creating a session with a custom ID ```python +import asyncio from copilot import CopilotClient -client = CopilotClient() -client.start() -# Create session with a memorable ID -session = client.create_session( - session_id="user-123-conversation", - model="gpt-5", -) +async def main(): + client = CopilotClient() + await client.start() -session.send(prompt="Let's discuss TypeScript generics") + # Create session with a memorable ID + session = await client.create_session({ + "session_id": "user-123-conversation", + }) -# Session ID is preserved -print(session.session_id) # "user-123-conversation" + await session.send_and_wait({"prompt": "Let's discuss TypeScript generics"}) -# Destroy session but keep data on disk -session.destroy() -client.stop() + # Session ID is preserved + print(session.session_id) # "user-123-conversation" + + # Destroy session but keep data on disk + await session.destroy() + await client.stop() + + +asyncio.run(main()) ``` ### Resuming a session ```python -client = CopilotClient() -client.start() +import asyncio +from copilot import CopilotClient + + +async def main(): + client = CopilotClient() + await client.start() + + # Resume the previous session + session = await client.resume_session("user-123-conversation") + + # Previous context is restored + await session.send_and_wait({"prompt": "What were we discussing?"}) -# Resume the previous session -session = client.resume_session("user-123-conversation") + await session.destroy() + await client.stop() -# Previous context is restored -session.send(prompt="What were we discussing?") -session.destroy() -client.stop() +asyncio.run(main()) ``` ### Listing available sessions ```python -sessions = client.list_sessions() +# List all sessions on disk +sessions = await client.list_sessions() for s in sessions: - print("Session:", s["sessionId"]) + print(f"Session: {s['session_id']}") + print(f" Created: {s['start_time']}") + print(f" Modified: {s['modified_time']}") + if s.get('summary'): + print(f" Summary: {s['summary']}") ``` ### Deleting a session permanently ```python # Remove session and all its data from disk -client.delete_session("user-123-conversation") +await client.delete_session("user-123-conversation") ``` ### Getting session history ```python -messages = session.get_messages() +messages = await session.get_messages() for msg in messages: - print(f"[{msg['type']}] {msg['data']}") + print(f"[{msg.type}] {msg.data}") ``` ## Best practices 1. **Use meaningful session IDs**: Include user ID or context in the session ID -2. **Handle missing sessions**: Check if a session exists before resuming -3. **Clean up old sessions**: Periodically delete sessions that are no longer needed +2. **Handle missing sessions**: Use `list_sessions()` to check if a session exists before resuming +3. **Clean up old sessions**: Use `delete_session()` to periodically remove sessions that are no longer needed diff --git a/cookbook/python/pr-visualization.md b/cookbook/python/pr-visualization.md index af2ce20c..4ae94aa9 100644 --- a/cookbook/python/pr-visualization.md +++ b/cookbook/python/pr-visualization.md @@ -27,55 +27,61 @@ pip install copilot-sdk ```bash # Auto-detect from current git repo -python pr_breakdown.py +python pr_visualization.py # Specify a repo explicitly -python pr_breakdown.py --repo github/copilot-sdk +python pr_visualization.py --repo github/copilot-sdk ``` -## Full example: pr_breakdown.py +## Full example: pr_visualization.py ```python #!/usr/bin/env python3 +import asyncio +import os +import re import subprocess import sys -import os from copilot import CopilotClient + # ============================================================================ # Git & GitHub Detection # ============================================================================ + def is_git_repo(): try: subprocess.run( ["git", "rev-parse", "--git-dir"], check=True, - capture_output=True + capture_output=True, ) return True except (subprocess.CalledProcessError, FileNotFoundError): return False + def get_github_remote(): try: result = subprocess.run( ["git", "remote", "get-url", "origin"], check=True, capture_output=True, - text=True + text=True, ) remote_url = result.stdout.strip() # Handle SSH: git@github.com:owner/repo.git - import re ssh_match = re.search(r"git@github\.com:(.+/.+?)(?:\.git)?$", remote_url) if ssh_match: return ssh_match.group(1) # Handle HTTPS: https://github.com/owner/repo.git - https_match = re.search(r"https://github\.com/(.+/.+?)(?:\.git)?$", remote_url) + https_match = re.search( + r"https://github\.com/(.+/.+?)(?:\.git)?$", remote_url + ) if https_match: return https_match.group(1) @@ -83,6 +89,7 @@ def get_github_remote(): except (subprocess.CalledProcessError, FileNotFoundError): return None + def parse_args(): args = sys.argv[1:] if "--repo" in args: @@ -91,14 +98,17 @@ def parse_args(): return {"repo": args[idx + 1]} return {} + def prompt_for_repo(): return input("Enter GitHub repo (owner/repo): ").strip() + # ============================================================================ # Main Application # ============================================================================ -def main(): + +async def main(): print("šŸ” PR Age Chart Generator\n") # Determine the repository @@ -126,13 +136,12 @@ def main(): owner, repo_name = repo.split("/", 1) - # Create Copilot client - no custom tools needed! - client = CopilotClient(log_level="error") - client.start() + # Create Copilot client + client = CopilotClient({"log_level": "error"}) + await client.start() - session = client.create_session( - model="gpt-5", - system_message={ + session = await client.create_session({ + "system_message": { "content": f""" You are analyzing pull requests for the GitHub repository: {owner}/{repo_name} @@ -147,30 +156,32 @@ The current working directory is: {os.getcwd()} """ } - ) + }) # Set up event handling def handle_event(event): - if event["type"] == "assistant.message": - print(f"\nšŸ¤– {event['data']['content']}\n") - elif event["type"] == "tool.execution_start": - print(f" āš™ļø {event['data']['toolName']}") + if event.type == "assistant.message": + print(f"\nšŸ¤– {event.data.content}\n") + elif event.type == "tool.execution_start": + print(f" āš™ļø {event.data.tool_name}") session.on(handle_event) # Initial prompt - let Copilot figure out the details print("\nšŸ“Š Starting analysis...\n") - session.send(prompt=f""" - Fetch the open pull requests for {owner}/{repo_name} from the last week. - Calculate the age of each PR in days. - Then generate a bar chart image showing the distribution of PR ages - (group them into sensible buckets like <1 day, 1-3 days, etc.). - Save the chart as "pr-age-chart.png" in the current directory. - Finally, summarize the PR health - average age, oldest PR, and how many might be considered stale. - """) - - session.wait_for_idle() + await session.send_and_wait({ + "prompt": f""" +Fetch the open pull requests for {owner}/{repo_name} from the last week. +Calculate the age of each PR in days. +Then generate a bar chart image showing the distribution of PR ages +(group them into sensible buckets like <1 day, 1-3 days, etc.). +Save the chart as "pr-age-chart.png" in the current directory. +Finally, summarize the PR health - average age, oldest PR, and how many might be considered stale. +""" + }, + timeout=300.0, # 5 minutes for complex GitHub API + chart generation + ) # Interactive loop print("\nšŸ’” Ask follow-up questions or type \"exit\" to quit.\n") @@ -182,20 +193,25 @@ The current working directory is: {os.getcwd()} print() while True: - user_input = input("You: ").strip() + try: + user_input = input("You: ").strip() + except (EOFError, KeyboardInterrupt): + print("\nšŸ‘‹ Goodbye!") + break if user_input.lower() in ["exit", "quit"]: print("šŸ‘‹ Goodbye!") break if user_input: - session.send(prompt=user_input) - session.wait_for_idle() + await session.send_and_wait({"prompt": user_input}, timeout=300.0) + + await session.destroy() + await client.stop() - client.stop() if __name__ == "__main__": - main() + asyncio.run(main()) ``` ## How it works diff --git a/cookbook/python/recipe/README.md b/cookbook/python/recipe/README.md index aab80173..442def64 100644 --- a/cookbook/python/recipe/README.md +++ b/cookbook/python/recipe/README.md @@ -25,7 +25,7 @@ python .py | Recipe | Command | Description | | -------------------- | -------------------------------- | ------------------------------------------ | -| Error Handling | `python error_handling.py` | Demonstrates error handling patterns | +| Error Handling | `python error_handling.py` | Demonstrates async 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 | @@ -46,9 +46,38 @@ python pr_visualization.py --repo github/copilot-sdk python managing_local_files.py ``` +## About the SDK API + +The Copilot SDK is fully asynchronous. All examples use `asyncio.run()` to run the async main function: + +```python +import asyncio +from copilot import CopilotClient + +async def main(): + client = CopilotClient() + await client.start() + + session = await client.create_session() + await session.send_and_wait({"prompt": "Hello!"}) + + await session.destroy() + await client.stop() + +if __name__ == "__main__": + asyncio.run(main()) +``` + +### Key API Patterns + +- **Async methods**: `start()`, `stop()`, `create_session()`, `send()`, `destroy()` all require `await` +- **Configuration dicts**: Pass options as dictionaries, e.g., `{"prompt": "Hello"}` +- **Event objects**: Events have `.type` and `.data` attributes (not dict access) +- **send_and_wait()**: Convenience method that sends and waits for completion + ## Local SDK Development -The `requirements.txt` installs the local Copilot SDK using `-e ../..` (editable install). This means: +The `requirements.txt` installs the local Copilot SDK using `-e ../../../python` (editable install). This means: - Changes to the SDK source are immediately available - No need to publish or install from PyPI @@ -64,7 +93,8 @@ These examples follow Python conventions: - Shebang line for direct execution - Proper exception handling - Type hints where appropriate -- Standard library usage +- Module docstrings for documentation +- Async/await patterns ## Virtual Environment (Recommended) @@ -87,6 +117,7 @@ pip install -r requirements.txt ## Learning Resources - [Python Documentation](https://docs.python.org/3/) +- [Python asyncio](https://docs.python.org/3/library/asyncio.html) - [PEP 8 Style Guide](https://pep8.org/) -- [GitHub Copilot SDK for Python](../../README.md) +- [GitHub Copilot SDK for Python](../../../python/README.md) - [Parent Cookbook](../README.md) diff --git a/cookbook/python/recipe/error_handling.py b/cookbook/python/recipe/error_handling.py index 57073037..6abc3672 100644 --- a/cookbook/python/recipe/error_handling.py +++ b/cookbook/python/recipe/error_handling.py @@ -1,28 +1,51 @@ #!/usr/bin/env python3 +""" +Error Handling Patterns - Demonstrates error handling with the Copilot SDK. -from copilot import CopilotClient +This example shows how to properly handle errors when working with the +async Copilot SDK, including connection failures, timeouts, and cleanup. +""" + +import asyncio -client = CopilotClient() +from copilot import CopilotClient -try: - client.start() - session = client.create_session(model="gpt-5") +async def main(): + client = CopilotClient() response = None + def handle_message(event): nonlocal response - if event["type"] == "assistant.message": - response = event["data"]["content"] + if event.type == "assistant.message": + response = event.data.content + + try: + await client.start() + + session = await client.create_session() + session.on(handle_message) + + # Use send_and_wait for simple request-response pattern + await session.send_and_wait({"prompt": "Hello!"}) + + if response: + print(response) + + await session.destroy() + + except FileNotFoundError: + print("Error: Copilot CLI not found. Please install it first.") + except ConnectionError: + print("Error: Could not connect to Copilot CLI server.") + except asyncio.TimeoutError: + print("Error: Request timed out.") + except Exception as e: + print(f"Error: {e}") - session.on(handle_message) - session.send(prompt="Hello!") - session.wait_for_idle() + finally: + await client.stop() - if response: - print(response) - session.destroy() -except Exception as e: - print(f"Error: {e}") -finally: - client.stop() +if __name__ == "__main__": + asyncio.run(main()) diff --git a/cookbook/python/recipe/managing_local_files.py b/cookbook/python/recipe/managing_local_files.py index 0fd43e50..2ce26355 100644 --- a/cookbook/python/recipe/managing_local_files.py +++ b/cookbook/python/recipe/managing_local_files.py @@ -1,31 +1,43 @@ #!/usr/bin/env python3 +""" +Managing Local Files - Demonstrates using Copilot to organize files. -from copilot import CopilotClient +This example shows how to use the Copilot SDK to intelligently organize +files in a folder based on metadata like file type, creation date, etc. +""" + +import asyncio import os -# Create and start client -client = CopilotClient() -client.start() +from copilot import CopilotClient + + +async def main(): + # Create and start client + client = CopilotClient() + await client.start() -# Create session -session = client.create_session(model="gpt-5") + # Create session + session = await client.create_session() -# Event handler -def handle_event(event): - if event["type"] == "assistant.message": - print(f"\nCopilot: {event['data']['content']}") - elif event["type"] == "tool.execution_start": - print(f" → Running: {event['data']['toolName']}") - elif event["type"] == "tool.execution_complete": - print(f" āœ“ Completed: {event['data']['toolCallId']}") + # Event handler + def handle_event(event): + if event.type == "assistant.message": + print(f"\nCopilot: {event.data.content}") + elif event.type == "tool.execution_start": + print(f" → Running: {event.data.tool_name}") + elif event.type == "tool.execution_complete": + print(f" āœ“ Completed: {event.data.tool_call_id}") -session.on(handle_event) + session.on(handle_event) -# Ask Copilot to organize files -# Change this to your target folder -target_folder = os.path.expanduser("~/Downloads") + # Ask Copilot to organize files + # Change this to your target folder + target_folder = os.path.expanduser("~/Downloads") -session.send(prompt=f""" + await session.send_and_wait( + { + "prompt": f""" Analyze the files in "{target_folder}" and organize them into subfolders. 1. First, list all files and their metadata @@ -34,9 +46,13 @@ def handle_event(event): 4. Move each file to its appropriate subfolder Please confirm before moving any files. -""") +""" + } + ) + + await session.destroy() + await client.stop() -session.wait_for_idle() -session.destroy() -client.stop() +if __name__ == "__main__": + asyncio.run(main()) diff --git a/cookbook/python/recipe/multiple_sessions.py b/cookbook/python/recipe/multiple_sessions.py index 92921d2d..c7a24835 100644 --- a/cookbook/python/recipe/multiple_sessions.py +++ b/cookbook/python/recipe/multiple_sessions.py @@ -1,35 +1,51 @@ #!/usr/bin/env python3 +""" +Multiple Sessions - Demonstrates managing multiple independent conversations. + +This example shows how to create and manage multiple conversation sessions +simultaneously, each with its own context and history. +""" + +import asyncio from copilot import CopilotClient -client = CopilotClient() -client.start() -# Create multiple independent sessions -session1 = client.create_session(model="gpt-5") -session2 = client.create_session(model="gpt-5") -session3 = client.create_session(model="claude-sonnet-4.5") +async def main(): + client = CopilotClient() + await client.start() + + # Create multiple independent sessions + session1 = await client.create_session() + session2 = await client.create_session() + session3 = await client.create_session({"model": "claude-sonnet-4"}) + + print("Created 3 independent sessions") + + # Each session maintains its own conversation history + # Send initial context to each session + await session1.send({"prompt": "You are helping with a Python project"}) + await session2.send({"prompt": "You are helping with a TypeScript project"}) + await session3.send({"prompt": "You are helping with a Go project"}) -print("Created 3 independent sessions") + print("Sent initial context to all sessions") -# Each session maintains its own conversation history -session1.send(prompt="You are helping with a Python project") -session2.send(prompt="You are helping with a TypeScript project") -session3.send(prompt="You are helping with a Go project") + # Follow-up messages stay in their respective contexts + # Use send_and_wait to ensure each response completes + await session1.send_and_wait({"prompt": "How do I create a virtual environment?"}) + await session2.send_and_wait({"prompt": "How do I set up tsconfig?"}) + await session3.send_and_wait({"prompt": "How do I initialize a module?"}) -print("Sent initial context to all sessions") + print("Sent follow-up questions to each session") -# Follow-up messages stay in their respective contexts -session1.send(prompt="How do I create a virtual environment?") -session2.send(prompt="How do I set up tsconfig?") -session3.send(prompt="How do I initialize a module?") + # Clean up all sessions + await session1.destroy() + await session2.destroy() + await session3.destroy() + await client.stop() -print("Sent follow-up questions to each session") + print("All sessions destroyed successfully") -# Clean up all sessions -session1.destroy() -session2.destroy() -session3.destroy() -client.stop() -print("All sessions destroyed successfully") +if __name__ == "__main__": + asyncio.run(main()) diff --git a/cookbook/python/recipe/persisting_sessions.py b/cookbook/python/recipe/persisting_sessions.py index 071ff1a8..532a64e5 100644 --- a/cookbook/python/recipe/persisting_sessions.py +++ b/cookbook/python/recipe/persisting_sessions.py @@ -1,36 +1,58 @@ #!/usr/bin/env python3 +""" +Session Persistence - Demonstrates saving and resuming conversation sessions. + +This example shows how to create sessions with custom IDs and resume them +later, allowing conversations to persist across application restarts. +""" + +import asyncio from copilot import CopilotClient -client = CopilotClient() -client.start() -# Create session with a memorable ID -session = client.create_session( - session_id="user-123-conversation", - model="gpt-5", -) +async def main(): + client = CopilotClient() + await client.start() + + # Create session with a memorable ID + session = await client.create_session( + { + "session_id": "user-123-conversation", + } + ) + + await session.send_and_wait({"prompt": "Let's discuss TypeScript generics"}) + print(f"Session created: {session.session_id}") + + # Destroy session but keep data on disk + await session.destroy() + print("Session destroyed (state persisted)") + + # Resume the previous session + resumed = await client.resume_session("user-123-conversation") + print(f"Resumed: {resumed.session_id}") + + await resumed.send_and_wait({"prompt": "What were we discussing?"}) -session.send(prompt="Let's discuss TypeScript generics") -print(f"Session created: {session.session_id}") + # Get session message history + messages = await resumed.get_messages() + print(f"Session has {len(messages)} messages") -# Destroy session but keep data on disk -session.destroy() -print("Session destroyed (state persisted)") + # List all available sessions + sessions = await client.list_sessions() + print("Available sessions:") + for s in sessions: + print(f" - {s['session_id']}: {s.get('summary', 'No summary')}") -# Resume the previous session -resumed = client.resume_session("user-123-conversation") -print(f"Resumed: {resumed.session_id}") + await resumed.destroy() -resumed.send(prompt="What were we discussing?") + # Delete session permanently (removes all data from disk) + await client.delete_session("user-123-conversation") + print("Session deleted permanently") -# List sessions -sessions = client.list_sessions() -print("Sessions:", [s["sessionId"] for s in sessions]) + await client.stop() -# Delete session permanently -client.delete_session("user-123-conversation") -print("Session deleted") -resumed.destroy() -client.stop() +if __name__ == "__main__": + asyncio.run(main()) diff --git a/cookbook/python/recipe/pr_visualization.py b/cookbook/python/recipe/pr_visualization.py index 72226c3d..952d46b4 100644 --- a/cookbook/python/recipe/pr_visualization.py +++ b/cookbook/python/recipe/pr_visualization.py @@ -1,50 +1,60 @@ #!/usr/bin/env python3 +""" +PR Age Chart Generator - Visualizes pull request age distribution. -import subprocess -import sys +This tool detects the current Git repo or accepts a repo as input, +then uses Copilot to fetch PR data and generate a chart image. +""" + +import asyncio import os import re +import subprocess +import sys + from copilot import CopilotClient # ============================================================================ # Git & GitHub Detection # ============================================================================ + def is_git_repo(): try: subprocess.run( ["git", "rev-parse", "--git-dir"], check=True, - capture_output=True + capture_output=True, ) return True except (subprocess.CalledProcessError, FileNotFoundError): return False + def get_github_remote(): try: result = subprocess.run( ["git", "remote", "get-url", "origin"], check=True, capture_output=True, - text=True + text=True, ) remote_url = result.stdout.strip() - # Handle SSH: git@github.com:owner/repo.git - ssh_match = re.search(r"git@github\.com:(.+/.+?)(?:\.git)?$", remote_url) - if ssh_match: - return ssh_match.group(1) + if ssh_match := re.search( + r"git@github\.com:(.+/.+?)(?:\.git)?$", remote_url + ): + return ssh_match[1] # Handle HTTPS: https://github.com/owner/repo.git - https_match = re.search(r"https://github\.com/(.+/.+?)(?:\.git)?$", remote_url) - if https_match: - return https_match.group(1) - - return None + https_match = re.search( + r"https://github\.com/(.+/.+?)(?:\.git)?$", remote_url + ) + return https_match[1] if https_match else None except (subprocess.CalledProcessError, FileNotFoundError): return None + def parse_args(): args = sys.argv[1:] if "--repo" in args: @@ -53,14 +63,17 @@ def parse_args(): return {"repo": args[idx + 1]} return {} + def prompt_for_repo(): return input("Enter GitHub repo (owner/repo): ").strip() + # ============================================================================ # Main Application # ============================================================================ -def main(): + +async def main(): print("šŸ” PR Age Chart Generator\n") # Determine the repository @@ -88,14 +101,14 @@ def main(): owner, repo_name = repo.split("/", 1) - # Create Copilot client - no custom tools needed! - client = CopilotClient(log_level="error") - client.start() + # Create Copilot client + client = CopilotClient({"log_level": "error"}) + await client.start() - session = client.create_session( - model="gpt-5", - system_message={ - "content": f""" + session = await client.create_session( + { + "system_message": { + "content": f""" You are analyzing pull requests for the GitHub repository: {owner}/{repo_name} The current working directory is: {os.getcwd()} @@ -108,31 +121,37 @@ def main(): - Be concise in your responses """ + } } ) # Set up event handling def handle_event(event): - if event["type"] == "assistant.message": - print(f"\nšŸ¤– {event['data']['content']}\n") - elif event["type"] == "tool.execution_start": - print(f" āš™ļø {event['data']['toolName']}") + """Handle incoming session events.""" + if event.type == "assistant.message": + print(f"\nšŸ¤– {event.data.content}\n") + elif event.type == "tool.execution_start": + print(f" āš™ļø {event.data.tool_name}") session.on(handle_event) # Initial prompt - let Copilot figure out the details print("\nšŸ“Š Starting analysis...\n") - session.send(prompt=f""" - Fetch the open pull requests for {owner}/{repo_name} from the last week. - Calculate the age of each PR in days. - Then generate a bar chart image showing the distribution of PR ages - (group them into sensible buckets like <1 day, 1-3 days, etc.). - Save the chart as "pr-age-chart.png" in the current directory. - Finally, summarize the PR health - average age, oldest PR, and how many might be considered stale. - """) - - session.wait_for_idle() + # Use a longer timeout (5 minutes) for complex operations like PR fetching and chart generation + await session.send_and_wait( + { + "prompt": f""" +Fetch the open pull requests for {owner}/{repo_name} from the last week. +Calculate the age of each PR in days. +Then generate a bar chart image showing the distribution of PR ages +(group them into sensible buckets like <1 day, 1-3 days, etc.). +Save the chart as "pr-age-chart.png" in the current directory. +Finally, summarize the PR health - average age, oldest PR, and how many might be considered stale. +""" + }, + timeout=300.0, # 5 minutes for complex GitHub API + chart generation + ) # Interactive loop print("\nšŸ’” Ask follow-up questions or type \"exit\" to quit.\n") @@ -144,18 +163,22 @@ def handle_event(event): print() while True: - user_input = input("You: ").strip() + try: + user_input = input("You: ").strip() + except (EOFError, KeyboardInterrupt): + print("\nšŸ‘‹ Goodbye!") + break if user_input.lower() in ["exit", "quit"]: print("šŸ‘‹ Goodbye!") break if user_input: - session.send(prompt=user_input) - session.wait_for_idle() + await session.send_and_wait({"prompt": user_input}, timeout=300.0) + + await session.destroy() + await client.stop() - session.destroy() - client.stop() if __name__ == "__main__": - main() + asyncio.run(main()) diff --git a/cookbook/python/recipe/requirements.txt b/cookbook/python/recipe/requirements.txt index 91d70ef1..b3dad4b1 100644 --- a/cookbook/python/recipe/requirements.txt +++ b/cookbook/python/recipe/requirements.txt @@ -1,2 +1,2 @@ # Install the local Copilot SDK package in editable mode --e ../.. +-e ../../../python diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index 47a4ab6d..f5961472 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -28,6 +28,7 @@ ResumeSessionConfig, SessionConfig, SessionEvent, + SessionMetadata, Tool, ToolHandler, ToolInvocation, @@ -59,6 +60,7 @@ "ResumeSessionConfig", "SessionConfig", "SessionEvent", + "SessionMetadata", "Tool", "ToolHandler", "ToolInvocation", diff --git a/python/copilot/client.py b/python/copilot/client.py index 030ee4f2..f1767ca6 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -12,6 +12,8 @@ ... await session.send({"prompt": "Hello!"}) """ +from __future__ import annotations + import asyncio import inspect import os @@ -33,6 +35,7 @@ ModelInfo, ResumeSessionConfig, SessionConfig, + SessionMetadata, ToolHandler, ToolInvocation, ToolResult, @@ -572,7 +575,7 @@ async def ping(self, message: Optional[str] = None) -> dict: return await self._client.request("ping", {"message": message}) - async def get_status(self) -> "GetStatusResponse": + async def get_status(self) -> GetStatusResponse: """ Get CLI status including version and protocol information. @@ -591,7 +594,7 @@ async def get_status(self) -> "GetStatusResponse": return await self._client.request("status.get", {}) - async def get_auth_status(self) -> "GetAuthStatusResponse": + async def get_auth_status(self) -> GetAuthStatusResponse: """ Get current authentication status. @@ -611,7 +614,7 @@ async def get_auth_status(self) -> "GetAuthStatusResponse": return await self._client.request("auth.getStatus", {}) - async def list_models(self) -> List["ModelInfo"]: + async def list_models(self) -> List[ModelInfo]: """ List available models with their metadata. @@ -633,6 +636,71 @@ async def list_models(self) -> List["ModelInfo"]: response = await self._client.request("models.list", {}) return response.get("models", []) + async def list_sessions(self) -> List[SessionMetadata]: + """ + List all available sessions known to the server. + + Returns metadata about each session including ID, timestamps, and summary. + + Returns: + A list of SessionMetadata objects with session details. + + Raises: + RuntimeError: If the client is not connected. + + Example: + >>> sessions = await client.list_sessions() + >>> for session in sessions: + ... print(f"{session['session_id']}: {session.get('summary', 'No summary')}") + """ + if not self._client: + raise RuntimeError("Client not connected") + + response = await self._client.request("session.list", {}) + sessions = response.get("sessions", []) + + # Convert wire format (camelCase) to Python format (snake_case) + return [ + SessionMetadata( + session_id=s["sessionId"], + start_time=s["startTime"], + modified_time=s["modifiedTime"], + summary=s.get("summary"), + is_remote=s.get("isRemote", False), + ) + for s in sessions + ] + + async def delete_session(self, session_id: str) -> None: + """ + Delete a session and its data from disk. + + This permanently removes the session and all its conversation history. + The session cannot be resumed after deletion. + + Args: + session_id: The ID of the session to delete. + + Raises: + RuntimeError: If the client is not connected or deletion fails. + + Example: + >>> await client.delete_session("user-123-conversation") + """ + if not self._client: + raise RuntimeError("Client not connected") + + response = await self._client.request("session.delete", {"sessionId": session_id}) + + success = response.get("success", False) + if not success: + error = response.get("error", "Unknown error") + raise RuntimeError(f"Failed to delete session {session_id}: {error}") + + # Remove from local sessions map if present + with self._sessions_lock: + self._sessions.pop(session_id, None) + async def _verify_protocol_version(self) -> None: """Verify that the server's protocol version matches the SDK's expected version.""" expected_version = get_sdk_protocol_version() diff --git a/python/copilot/types.py b/python/copilot/types.py index 6a4d0b8d..68d06ff2 100644 --- a/python/copilot/types.py +++ b/python/copilot/types.py @@ -5,7 +5,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Awaitable, Callable, Dict, List, Literal, TypedDict, Union +from typing import Any, Awaitable, Callable, Dict, List, Literal, Optional, TypedDict, Union from typing_extensions import NotRequired @@ -86,7 +86,7 @@ class Tool: name: str description: str handler: ToolHandler - parameters: Dict[str, Any] | None = None + parameters: Optional[Dict[str, Any]] = None # System message configuration (discriminated union) @@ -185,7 +185,7 @@ class CustomAgentConfig(TypedDict, total=False): display_name: NotRequired[str] # Display name for UI purposes description: NotRequired[str] # Description of what the agent does # List of tool names the agent can use - tools: NotRequired[List[str] | None] + tools: NotRequired[Optional[List[str]]] prompt: str # The prompt content for the agent # MCP servers specific to agent mcp_servers: NotRequired[Dict[str, MCPServerConfig]] @@ -201,9 +201,9 @@ class SessionConfig(TypedDict, total=False): tools: List[Tool] system_message: SystemMessageConfig # System message configuration # List of tool names to allow (takes precedence over excluded_tools) - available_tools: list[str] + available_tools: List[str] # List of tool names to disable (ignored if available_tools is set) - excluded_tools: list[str] + excluded_tools: List[str] # Handler for permission requests from the server on_permission_request: PermissionHandler # Custom provider configuration (BYOK - Bring Your Own Key) @@ -281,6 +281,17 @@ class MessageOptions(TypedDict): SessionEventHandler = Callable[[SessionEvent], None] +# Session metadata returned by list_sessions +class SessionMetadata(TypedDict): + """Metadata about a session returned by list_sessions.""" + + session_id: str # The unique session identifier + start_time: str # ISO 8601 timestamp when session was created + modified_time: str # ISO 8601 timestamp when session was last modified + summary: NotRequired[str] # Optional summary of the session + is_remote: bool # Whether the session is remote + + # Response from status.get class GetStatusResponse(TypedDict): """Response from status.get""" diff --git a/python/e2e/test_mcp_and_agents.py b/python/e2e/test_mcp_and_agents.py index 95738d5e..a8e45765 100644 --- a/python/e2e/test_mcp_and_agents.py +++ b/python/e2e/test_mcp_and_agents.py @@ -2,6 +2,8 @@ Tests for MCP servers and custom agents functionality """ +from __future__ import annotations + import pytest from copilot import CustomAgentConfig, MCPServerConfig diff --git a/python/e2e/test_tools.py b/python/e2e/test_tools.py index 2e024887..bb573519 100644 --- a/python/e2e/test_tools.py +++ b/python/e2e/test_tools.py @@ -1,5 +1,7 @@ """E2E Tools Tests""" +from __future__ import annotations + import os import pytest diff --git a/python/pyproject.toml b/python/pyproject.toml index 50a2c777..b19c1d2b 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -22,6 +22,8 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] dependencies = [ "python-dateutil>=2.9.0.post0", @@ -64,7 +66,11 @@ select = [ "UP", # pyupgrade ] ignore = [ - "UP006", + "UP006", # Use `type` instead of `Type` for type annotation + "UP007", # Use `X | Y` for type annotations (not compatible with Python 3.8/3.9 without PEP 604) + "UP035", # Use `typing.X` instead of deprecated import + "UP037", # Remove quotes from type annotation (keep for forward references) + "UP045", # Use `X | None` for type annotations (Optional[X] is more compatible) ] [tool.ruff.format]