agent-cli-sdk 0.0.1a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-cli-sdk
3
+ Version: 0.0.1a1
4
+ Summary: Universal SDK for interacting with AI Agent CLIs (Gemini, Copilot, etc.)
5
+ Project-URL: Homepage, https://github.com/rbbtsn0w/agent-cli-sdk
6
+ Project-URL: Repository, https://github.com/rbbtsn0w/agent-cli-sdk
7
+ Project-URL: Issues, https://github.com/rbbtsn0w/agent-cli-sdk/issues
8
+ Author-email: Agent CLI SDK Contributors <noreply@github.com>
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 Agent CLI SDK Contributors
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Classifier: Development Status :: 3 - Alpha
32
+ Classifier: Intended Audience :: Developers
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Programming Language :: Python :: 3
35
+ Classifier: Programming Language :: Python :: 3.8
36
+ Classifier: Programming Language :: Python :: 3.9
37
+ Classifier: Programming Language :: Python :: 3.10
38
+ Classifier: Programming Language :: Python :: 3.11
39
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
40
+ Requires-Python: >=3.8
41
+ Requires-Dist: pydantic>=2.0
42
+ Provides-Extra: dev
43
+ Requires-Dist: pre-commit; extra == 'dev'
44
+ Requires-Dist: ruff; extra == 'dev'
45
+ Requires-Dist: vulture; extra == 'dev'
46
+ Provides-Extra: test
47
+ Requires-Dist: pytest; extra == 'test'
48
+ Requires-Dist: pytest-asyncio; extra == 'test'
49
+ Requires-Dist: pytest-cov; extra == 'test'
50
+ Requires-Dist: pytest-timeout; extra == 'test'
51
+ Description-Content-Type: text/markdown
52
+
53
+ # Agent CLI SDK
54
+
55
+ [![CI](https://github.com/rbbtsn0w/agent-cli-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/rbbtsn0w/agent-cli-sdk/actions)
56
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
57
+ [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/)
58
+
59
+ > **Note:** This project is currently in **Technical Preview**.
60
+
61
+ The `agent-cli-sdk` provides a **universal, programmable interface** for interacting with AI Agent CLIs like **GitHub Copilot** and **Google Gemini**.
62
+
63
+ By leveraging your local CLI tools as the "runtime engine," this SDK allows you to build sophisticated agentic applications while reusing existing authentication, session persistence, and local context without managing complex API keys or HTTP clients manually.
64
+
65
+ ## 🚀 Key Features
66
+
67
+ * **Universal Agent Interface:** Write your business logic once using `UniversalAgent` and switch between drivers (`CopilotDriver`, `GeminiDriver`, `MockDriver`) seamlessly.
68
+ * **CLI as a Runtime:** Reuses local binary capabilities (auth, tool execution, context awareness).
69
+ * **Full Protocol Support:**
70
+ * **GitHub Copilot:** Full JSON-RPC support (LSP-style framing) with bidirectional tool execution.
71
+ * **Google Gemini:** Robust CLI wrapper with streaming JSON event parsing.
72
+ * **ReAct Loop & Custom Tools:** Register any Python function as a tool; the SDK handles the thought-action-observation loop automatically.
73
+ * **Stateful Sessions:** Built-in support for capturing and resuming CLI session IDs across application restarts.
74
+
75
+ ## 📦 Installation
76
+
77
+ ```bash
78
+ pip install agent-cli-sdk
79
+ ```
80
+
81
+ *Ensure you have the corresponding CLI installed:*
82
+ - **Gemini:** `brew install gemini-cli` shoud be login
83
+ - **Copilot:** `brew install copilot-cli` shoud be login
84
+
85
+ ## 🛠 Quick Start
86
+
87
+ One logic, any engine. Here is how you run a simple chat with Gemini:
88
+
89
+ ```python
90
+ import asyncio
91
+ from agent_sdk.core.agent import UniversalAgent
92
+ from agent_sdk.drivers.gemini_driver import GeminiDriver
93
+
94
+ async def main():
95
+ # 1. Choose your engine
96
+ driver = GeminiDriver()
97
+
98
+ # 2. Initialize the Universal Agent
99
+ agent = UniversalAgent(driver)
100
+
101
+ # 3. Stream responses
102
+ print("User: Explain quantum computing.")
103
+ async for event in agent.stream("Explain quantum computing."):
104
+ if event.type.name == "CONTENT":
105
+ print(event.payload, end="", flush=True)
106
+
107
+ if __name__ == "__main__":
108
+ asyncio.run(main())
109
+ ```
110
+
111
+ ## 🎮 Explore Universal Demos
112
+
113
+ We provide a **Guided Launcher** to explore all SDK capabilities across different drivers.
114
+
115
+ ```bash
116
+ python3 examples/demo_launcher.py
117
+ ```
118
+
119
+ The launcher will:
120
+ 1. **Auto-detect** your environment (checking if `gemini` or `copilot` is installed).
121
+ 2. Allow you to **select a Driver Engine**.
122
+ 3. Let you **select a Task** (Chat, Custom Tools, Session Persistence, etc.) to run on that engine.
123
+
124
+ ## 🏗 Architecture
125
+
126
+ The SDK follows a modular "Driver-Controller" pattern:
127
+
128
+ - **`UniversalAgent`**: The high-level controller. Manages message history, tool registration, and the ReAct execution loop.
129
+ - **`AgentDriver`**: The abstraction layer.
130
+ - **`CopilotDriver`**: Implements JSON-RPC 2.0 over Stdio (LSP framing). Supports server-side requests (the CLI asking the SDK to run a tool).
131
+ - **`GeminiDriver`**: Implements the CLI wrapper pattern with `-o stream-json` support.
132
+ - **`JsonRpcClient`**: A robust, async-first JSON-RPC client designed for high-concurrency CLI communication.
133
+
134
+ ## 🧪 Development & Testing
135
+
136
+ We maintain a high-quality codebase with extensive test coverage.
137
+
138
+ * **Unit & Integration Tests:** 100% pass rate across 29 core test cases.
139
+ * **Code Coverage:** **82%** (targeting 90%+).
140
+ * **Stability:** Built-in execution timeouts and automated cleanup for subprocesses.
141
+
142
+ ### Running Tests
143
+ ```bash
144
+ # Run all tests with coverage report
145
+ pytest --cov=src/agent_sdk tests/ --ignore=tests/e2e --cov-report=term-missing --timeout=5
146
+ ```
147
+
148
+ ### E2E Testing
149
+ E2E tests require authenticated local CLIs:
150
+ ```bash
151
+ # Run Gemini E2E
152
+ pytest tests/e2e/test_gemini_e2e.py
153
+ ```
154
+
155
+ ## 📄 License
156
+
157
+ Distributed under the MIT License. See `LICENSE` for more information.
@@ -0,0 +1,14 @@
1
+ agent_sdk/__init__.py,sha256=vJQ-cPjPzpZ9SVJhTbE3WlkpJwfi7wFiKtV5UlaItRw,637
2
+ agent_sdk/core/agent.py,sha256=gCcNl-Vzki3xpiEgR2Qr9h5zq6UufDyalnnkweqZfCE,4345
3
+ agent_sdk/core/driver.py,sha256=ziaFzzljhbK8OahECYB7YwJIooJBTNm6dbwSdSWy9yk,1321
4
+ agent_sdk/core/types.py,sha256=iX6EG415aUjYolaqy3PXqgFYyBe-r18g5cSLhtkzXXk,833
5
+ agent_sdk/drivers/cli_json_driver.py,sha256=gS7FxYuoEe4JXKnBg4DkUCdqQhq2aCqfrz0CGpq0Ru4,4099
6
+ agent_sdk/drivers/copilot_driver.py,sha256=UfeQkGNj84zlpnARCJaSY2ZguL2dShwXGEmM-mxmyxk,11284
7
+ agent_sdk/drivers/gemini_driver.py,sha256=9g614JU0pOW7K34MDNhI1P_goHk6ENKqyevGjXECMhE,2624
8
+ agent_sdk/drivers/mock_driver.py,sha256=886li_zzrbyGThBFNKaHjyd9dRMcnqScCixUlVcspvA,2819
9
+ agent_sdk/utils/jsonrpc.py,sha256=-geN11En2NqiupOJIeA27PbQHEMwSVLycLoFg8jzBxU,6952
10
+ agent_sdk/utils/schema.py,sha256=lWHUPn6pKMTvnVN0ioHqpk5gDuaD9VUnal_gLEf2u6E,1384
11
+ agent_cli_sdk-0.0.1a1.dist-info/METADATA,sha256=WOP49OnNMwChwiI5Ylb5O8fSseNcLQ3Tq2l5LKrbPEM,6792
12
+ agent_cli_sdk-0.0.1a1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
13
+ agent_cli_sdk-0.0.1a1.dist-info/licenses/LICENSE,sha256=6s8XT3wuJnvVrT9Gw_9YXJRtUWGku4YTjNC66pxxw8U,1083
14
+ agent_cli_sdk-0.0.1a1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Agent CLI SDK Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
agent_sdk/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ """
2
+ Agent CLI SDK - Universal interface for AI Agent CLIs (Copilot, Gemini, etc.)
3
+ """
4
+
5
+ __version__ = "0.0.1a1"
6
+
7
+ from agent_sdk.core.agent import UniversalAgent
8
+ from agent_sdk.core.driver import AgentDriver
9
+ from agent_sdk.core.types import AgentEvent, EventType, ToolDefinition
10
+ from agent_sdk.drivers.copilot_driver import CopilotDriver
11
+ from agent_sdk.drivers.gemini_driver import GeminiDriver
12
+ from agent_sdk.drivers.mock_driver import MockDriver
13
+
14
+ __all__ = [
15
+ "__version__",
16
+ "UniversalAgent",
17
+ "AgentDriver",
18
+ "AgentEvent",
19
+ "EventType",
20
+ "ToolDefinition",
21
+ "CopilotDriver",
22
+ "GeminiDriver",
23
+ "MockDriver",
24
+ ]
@@ -0,0 +1,124 @@
1
+ import inspect
2
+ import json
3
+ from typing import Any, AsyncGenerator, Callable, Dict, List
4
+
5
+ from ..utils.schema import generate_tool_schema
6
+ from .driver import AgentDriver
7
+ from .types import AgentEvent, Message, Role, StreamEvent, ToolDefinition
8
+
9
+
10
+ class UniversalAgent:
11
+ """
12
+ The main entry point for the SDK. Developers interact with this class.
13
+ It delegates the actual execution to a specific 'driver'.
14
+ """
15
+
16
+ def __init__(self, driver: AgentDriver, system_instruction: str = ""):
17
+ self._driver = driver
18
+ self._system_instruction = system_instruction
19
+ self._tools: List[ToolDefinition] = []
20
+ self._history: List[Message] = []
21
+
22
+ # Initialize driver configuration
23
+ self._driver.set_system_prompt(system_instruction)
24
+
25
+ def tool(self, func: Callable):
26
+ """
27
+ Decorator to register a function as a tool.
28
+ """
29
+ schema = generate_tool_schema(func)
30
+
31
+ tool_def = ToolDefinition(
32
+ name=func.__name__,
33
+ description=func.__doc__ or "",
34
+ parameters=schema,
35
+ function=func,
36
+ )
37
+ self._tools.append(tool_def)
38
+ self._driver.set_tools(self._tools)
39
+ return func
40
+
41
+ async def chat(self, user_input: str) -> str:
42
+ """
43
+ Simple non-streaming chat interface.
44
+ Returns the final assistant response text.
45
+ """
46
+ response_text = ""
47
+ async for event in self.stream(user_input):
48
+ if event.type == AgentEvent.CONTENT:
49
+ response_text += event.payload
50
+ return response_text
51
+
52
+ async def stream(self, user_input: str) -> AsyncGenerator[StreamEvent, None]:
53
+ """
54
+ Streaming interface that yields events (thoughts, tool calls, content).
55
+ Handles the ReAct loop (Agent -> Tool Call -> Execute -> Tool Result -> Agent).
56
+ """
57
+ # 1. Add user message to history
58
+ self._history.append(Message(role=Role.USER, content=user_input))
59
+
60
+ # ReAct loop: continues as long as there are tool calls to process
61
+ max_turns = 10
62
+ for _ in range(max_turns):
63
+ has_tool_calls = False
64
+
65
+ async for event in self._driver.chat(self._history):
66
+ yield event
67
+
68
+ if event.type == AgentEvent.TOOL_CALL:
69
+ has_tool_calls = True
70
+ result = await self._execute_tool(event.payload)
71
+
72
+ try:
73
+ await self._driver.send_tool_result(event.payload, result)
74
+ except NotImplementedError:
75
+ pass
76
+
77
+ self._history.append(
78
+ Message(
79
+ role=Role.TOOL,
80
+ content=str(result),
81
+ name=event.payload.get("name"),
82
+ )
83
+ )
84
+
85
+ yield StreamEvent(
86
+ type=AgentEvent.TOOL_RESULT,
87
+ payload={"call_id": event.payload.get("id"), "result": result},
88
+ )
89
+
90
+ if event.type == AgentEvent.DONE or event.type == AgentEvent.ERROR:
91
+ break
92
+
93
+ # If the last stream didn't result in more tool calls,
94
+ # the conversation turn is complete
95
+ if not has_tool_calls:
96
+ break
97
+
98
+ async def _execute_tool(self, call_info: Dict[str, Any]) -> Any:
99
+ """Executes a tool based on the call info."""
100
+ name = call_info.get("name")
101
+ args = call_info.get("arguments", {})
102
+
103
+ # Find the tool
104
+ tool = next((t for t in self._tools if t.name == name), None)
105
+ if not tool:
106
+ return {"error": f"Tool '{name}' not found", "status": "error"}
107
+
108
+ try:
109
+ # Handle if args is a JSON string or dict
110
+ if isinstance(args, str):
111
+ args = json.loads(args)
112
+
113
+ # Call the function
114
+ # Check if it's a coroutine
115
+ if inspect.iscoroutinefunction(tool.function):
116
+ result = await tool.function(**args)
117
+ else:
118
+ result = tool.function(**args)
119
+ return result
120
+ except Exception as e:
121
+ return {
122
+ "error": f"Error executing tool '{name}': {str(e)}",
123
+ "status": "error",
124
+ }
@@ -0,0 +1,46 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any, AsyncGenerator, List
3
+
4
+ from .types import Message, StreamEvent, ToolDefinition
5
+
6
+
7
+ class AgentDriver(ABC):
8
+ """
9
+ The abstract base class that all specific CLI drivers must implement.
10
+ This acts as the 'Adapter' in our architecture.
11
+ """
12
+
13
+ @abstractmethod
14
+ async def start(self) -> None:
15
+ """Initialize the underlying CLI process or connection."""
16
+ pass
17
+
18
+ @abstractmethod
19
+ async def stop(self) -> None:
20
+ """Terminate the underlying CLI process or connection."""
21
+ pass
22
+
23
+ @abstractmethod
24
+ def set_tools(self, tools: List[ToolDefinition]) -> None:
25
+ """Register tools with the underlying agent."""
26
+ pass
27
+
28
+ @abstractmethod
29
+ def set_system_prompt(self, prompt: str) -> None:
30
+ """Set the system instruction."""
31
+ pass
32
+
33
+ @abstractmethod
34
+ async def chat(self, messages: List[Message]) -> AsyncGenerator[StreamEvent, None]:
35
+ """
36
+ Send a conversation history to the agent and yield events back.
37
+ """
38
+ pass
39
+
40
+ @abstractmethod
41
+ async def send_tool_result(self, call_id: str, result: Any) -> None:
42
+ """
43
+ Send the result of a tool execution back to the agent
44
+ (required by some protocols like JSON-RPC).
45
+ """
46
+ pass
@@ -0,0 +1,46 @@
1
+ from dataclasses import dataclass, field
2
+ from enum import Enum
3
+ from typing import Any, Dict, List, Optional
4
+
5
+
6
+ class Role(Enum):
7
+ SYSTEM = "system"
8
+ USER = "user"
9
+ ASSISTANT = "assistant"
10
+ TOOL = "tool"
11
+
12
+
13
+ @dataclass
14
+ class Message:
15
+ role: Role
16
+ content: str
17
+ name: Optional[str] = None
18
+ tool_calls: Optional[List[Dict[str, Any]]] = None
19
+
20
+
21
+ @dataclass
22
+ class ToolDefinition:
23
+ name: str
24
+ description: str
25
+ parameters: Dict[str, Any] # JSON Schema
26
+ function: callable = field(repr=False)
27
+
28
+
29
+ class AgentEvent(Enum):
30
+ START = "start"
31
+ THOUGHT = "thought"
32
+ TOOL_CALL = "tool_call"
33
+ TOOL_RESULT = "tool_result"
34
+ CONTENT = "content"
35
+ DONE = "done"
36
+ ERROR = "error"
37
+
38
+
39
+ @dataclass
40
+ class StreamEvent:
41
+ type: AgentEvent
42
+ payload: Any
43
+
44
+
45
+ # Backward-compatible alias
46
+ EventType = AgentEvent
@@ -0,0 +1,116 @@
1
+ import asyncio
2
+ import json
3
+ from typing import Any, AsyncGenerator, Dict, List, Optional
4
+
5
+ from ..core.driver import AgentDriver
6
+ from ..core.types import AgentEvent, Message, Role, StreamEvent, ToolDefinition
7
+
8
+
9
+ class CliJsonDriver(AgentDriver):
10
+ """
11
+ A generic driver for AI CLIs that output Newline-Delimited JSON (JSONL).
12
+ Examples: gemini -o stream-json, copilot --stream-json (hypothetical)
13
+ """
14
+
15
+ def __init__(
16
+ self,
17
+ executable_path: str,
18
+ base_args: List[str] = None,
19
+ session_id: Optional[str] = None,
20
+ ):
21
+ self.executable_path = executable_path
22
+ self.base_args = base_args or []
23
+ self.session_id = session_id
24
+ self.tools: List[ToolDefinition] = []
25
+ self.system_instruction: str = ""
26
+
27
+ async def start(self) -> None:
28
+ try:
29
+ process = await asyncio.create_subprocess_exec(
30
+ self.executable_path,
31
+ "--version",
32
+ stdout=asyncio.subprocess.PIPE,
33
+ stderr=asyncio.subprocess.PIPE,
34
+ )
35
+ await process.wait()
36
+ except FileNotFoundError as e:
37
+ raise RuntimeError(f"CLI not found at '{self.executable_path}'") from e
38
+
39
+ async def stop(self) -> None:
40
+ pass
41
+
42
+ def set_tools(self, tools: List[ToolDefinition]) -> None:
43
+ self.tools = tools
44
+
45
+ def set_system_prompt(self, prompt: str) -> None:
46
+ self.system_instruction = prompt
47
+
48
+ async def chat(self, messages: List[Message]) -> AsyncGenerator[StreamEvent, None]:
49
+ last_message = messages[-1]
50
+ if last_message.role != Role.USER:
51
+ return
52
+
53
+ cmd = [self.executable_path] + self.base_args
54
+
55
+ # This part is still a bit tool-specific (session resume)
56
+ # But we can generalize it with a hook or specific args
57
+ if self.session_id:
58
+ # Standardizing on --resume if possible, or skip
59
+ cmd.extend(["--resume", self.session_id])
60
+
61
+ cmd.append(last_message.content)
62
+
63
+ process = await asyncio.create_subprocess_exec(
64
+ *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
65
+ )
66
+
67
+ if process.stdout:
68
+ async for line in process.stdout:
69
+ line_text = line.decode("utf-8").strip()
70
+ if not line_text:
71
+ continue
72
+
73
+ try:
74
+ data = json.loads(line_text)
75
+ # We expect the CLI to follow a standard event format:
76
+ # {"type": "content|tool_use|...", "content": "..."}
77
+ # If it follows Gemini's format, we map it:
78
+ for event in self._map_event(data):
79
+ yield event
80
+ except json.JSONDecodeError:
81
+ pass
82
+ await process.wait()
83
+ if process.returncode != 0:
84
+ stderr = await process.stderr.read()
85
+ yield StreamEvent(
86
+ type=AgentEvent.ERROR, payload=f"CLI failed: {stderr.decode()}"
87
+ )
88
+
89
+ def _map_event(self, data: Dict[str, Any]) -> List[StreamEvent]:
90
+ # Mapping logic (can be overridden by subclasses)
91
+ # For now, default to Gemini-style
92
+ res = []
93
+ t = data.get("type")
94
+ if t == "init":
95
+ self.session_id = data.get("session_id")
96
+ elif t == "message" and data.get("role") == "assistant":
97
+ res.append(
98
+ StreamEvent(type=AgentEvent.CONTENT, payload=data.get("content"))
99
+ )
100
+ elif t == "tool_use":
101
+ res.append(
102
+ StreamEvent(
103
+ type=AgentEvent.TOOL_CALL,
104
+ payload={
105
+ "name": data.get("tool_name"),
106
+ "arguments": data.get("parameters"),
107
+ "id": data.get("tool_id"),
108
+ },
109
+ )
110
+ )
111
+ elif t == "result":
112
+ res.append(StreamEvent(type=AgentEvent.DONE, payload=None))
113
+ return res
114
+
115
+ async def send_tool_result(self, call_id: str, result: Any) -> None:
116
+ pass
@@ -0,0 +1,281 @@
1
+ import asyncio
2
+ from typing import Any, AsyncGenerator, Callable, List, Optional
3
+
4
+ from ..core.driver import AgentDriver
5
+ from ..core.types import AgentEvent, Message, StreamEvent, ToolDefinition
6
+ from ..utils.jsonrpc import JsonRpcClient
7
+
8
+
9
+ class CopilotDriver(AgentDriver):
10
+ """
11
+ Driver for GitHub Copilot CLI, strictly aligned with Official SDK logic.
12
+ """
13
+
14
+ def __init__(
15
+ self,
16
+ executable_path: str = "copilot",
17
+ cli_path: Optional[str] = None,
18
+ session_id: Optional[str] = None,
19
+ model: Optional[str] = None,
20
+ cwd: Optional[str] = None,
21
+ env: Optional[dict] = None,
22
+ mcp_servers: Optional[dict] = None,
23
+ custom_agents: Optional[List[dict]] = None,
24
+ on_permission_request: Optional[Callable] = None,
25
+ skill_directories: Optional[List[str]] = None,
26
+ excluded_tools: Optional[List[str]] = None,
27
+ system_message: Optional[dict] = None,
28
+ ):
29
+ # Allow either executable_path or cli_path for flexibility
30
+ path = cli_path or executable_path
31
+ # Official SDK uses: --server --log-level info --stdio
32
+ full_command = f"{path} --server --log-level info --stdio"
33
+ self.client = JsonRpcClient(full_command, cwd=cwd, env=env)
34
+ self.tools: List[ToolDefinition] = []
35
+ self.session_id = session_id
36
+ self.model = model
37
+ self.system_prompt: str = ""
38
+ self._is_resumed = False if session_id is None else True
39
+
40
+ # Extended configuration
41
+ self.mcp_servers = mcp_servers
42
+ self.custom_agents = custom_agents
43
+ self.on_permission_request = on_permission_request
44
+ self.skill_directories = skill_directories
45
+ self.excluded_tools = excluded_tools
46
+ self.system_message = system_message
47
+
48
+ async def start(self) -> None:
49
+ # 1. Start Subprocess
50
+ await self.client.start()
51
+
52
+ # 2. Ping (Official Handshake)
53
+ try:
54
+ await self.client.request("ping", {"message": "hello"})
55
+ except Exception as e:
56
+ raise RuntimeError(f"Failed to connect to Copilot CLI: {e}") from e
57
+
58
+ async def create_session(self) -> str:
59
+ """Explicitly create a new session."""
60
+ payload = {}
61
+ if self.system_message:
62
+ payload["system_message"] = self.system_message
63
+ elif self.system_prompt:
64
+ payload["config"] = {"system_prompt": self.system_prompt}
65
+ else:
66
+ payload["config"] = {"system_prompt": ""}
67
+
68
+ if self.model:
69
+ payload["model"] = self.model
70
+ if self.tools:
71
+ payload["tools"] = [
72
+ {
73
+ "name": t.name,
74
+ "description": t.description,
75
+ "parameters": t.parameters,
76
+ }
77
+ for t in self.tools
78
+ ]
79
+ if self.mcp_servers:
80
+ payload["mcp_servers"] = self.mcp_servers
81
+ if self.custom_agents:
82
+ payload["custom_agents"] = self.custom_agents
83
+ if self.skill_directories:
84
+ payload["skill_directories"] = self.skill_directories
85
+ if self.excluded_tools:
86
+ payload["excluded_tools"] = self.excluded_tools
87
+
88
+ res = await self.client.request("session.create", payload)
89
+ self.session_id = res.get("sessionId")
90
+ self._is_resumed = False
91
+ return self.session_id
92
+
93
+ async def destroy_session(self, session_id: str) -> None:
94
+ """Explicitly destroy a session."""
95
+ await self.client.request("session.destroy", {"sessionId": session_id})
96
+ if self.session_id == session_id:
97
+ self.session_id = None
98
+
99
+ async def get_messages(self, session_id: str) -> List[dict]:
100
+ """Get messages for a session."""
101
+ res = await self.client.request(
102
+ "session.getMessages", {"sessionId": session_id}
103
+ )
104
+ return res.get("messages", [])
105
+
106
+ async def _ensure_session(self):
107
+ """Internal helper to ensure session is active."""
108
+ if self.session_id and self._is_resumed:
109
+ # We already have a session ID and it was intended to be resumed.
110
+ # Official SDK calls 'session.resume'
111
+ payload = {"sessionId": self.session_id}
112
+ if self.tools:
113
+ payload["tools"] = [
114
+ {
115
+ "name": t.name,
116
+ "description": t.description,
117
+ "parameters": t.parameters,
118
+ }
119
+ for t in self.tools
120
+ ]
121
+ if self.mcp_servers:
122
+ payload["mcp_servers"] = self.mcp_servers
123
+ if self.custom_agents:
124
+ payload["custom_agents"] = self.custom_agents
125
+ if self.skill_directories:
126
+ payload["skill_directories"] = self.skill_directories
127
+ if self.excluded_tools:
128
+ payload["excluded_tools"] = self.excluded_tools
129
+ if self.system_message:
130
+ payload["system_message"] = self.system_message
131
+
132
+ try:
133
+ res = await self.client.request("session.resume", payload)
134
+ self.session_id = res.get("sessionId")
135
+ self._is_resumed = False # Mark as active
136
+ except Exception as e:
137
+ print(
138
+ f"[CopilotDriver] Warning: Failed to resume session "
139
+ f"{self.session_id}: {e}"
140
+ )
141
+ # Fallback to create
142
+ self.session_id = None
143
+
144
+ if not self.session_id:
145
+ await self.create_session()
146
+
147
+ async def stop(self) -> None:
148
+ if self.session_id:
149
+ try:
150
+ await self.client.request(
151
+ "session.destroy", {"sessionId": self.session_id}
152
+ )
153
+ except Exception:
154
+ pass
155
+ await self.client.stop()
156
+
157
+ def set_tools(self, tools: List[ToolDefinition]) -> None:
158
+ self.tools = tools
159
+
160
+ def set_system_prompt(self, prompt: str) -> None:
161
+ self.system_prompt = prompt
162
+
163
+ async def chat(self, messages: List[Message]) -> AsyncGenerator[StreamEvent, None]:
164
+ # Ensure we have an active session
165
+ await self._ensure_session()
166
+
167
+ last_message = messages[-1]
168
+
169
+ # 3. session.send (Official Request)
170
+ # Official SDK returns messageId
171
+ await self.client.request(
172
+ "session.send",
173
+ {"sessionId": self.session_id, "prompt": last_message.content},
174
+ )
175
+
176
+ # 4. Event Loop (Official Events)
177
+ # Events come as notifications with method 'session.event'
178
+ while True:
179
+ notification = await self.client.get_notification()
180
+ print(f"[CopilotDriver] Received notification: {notification}")
181
+
182
+ # Handle Server Requests (Tools)
183
+ if notification.get("type") == "server_request":
184
+ payload = notification.get("payload")
185
+ method = payload.get("method")
186
+ req_id = payload.get("id")
187
+ params = payload.get("params", {})
188
+
189
+ if method == "tool.call":
190
+ # Official method name is 'tool.call'
191
+ # The 'id' must be the RPC request ID so we can respond correctly.
192
+
193
+ # Handle permission if callback is provided
194
+ if self.on_permission_request:
195
+ req = {
196
+ "kind": params.get("toolName"), # Simplified for now
197
+ "toolCallId": params.get("toolCallId"),
198
+ }
199
+ # Add more details if it's a known tool type
200
+ if params.get("toolName") in ["edit", "create", "delete"]:
201
+ req["kind"] = "write"
202
+ elif params.get("toolName") == "shell":
203
+ req["kind"] = "shell"
204
+
205
+ # Call the handler (could be sync or async)
206
+ if asyncio.iscoroutinefunction(self.on_permission_request):
207
+ perm_res = await self.on_permission_request(
208
+ req, {"session_id": self.session_id}
209
+ )
210
+ else:
211
+ perm_res = self.on_permission_request(
212
+ req, {"session_id": self.session_id}
213
+ )
214
+
215
+ if perm_res.get("kind") != "approved":
216
+ # If not approved, we send a failure response immediately
217
+ await self.client.send_response(
218
+ req_id,
219
+ result={
220
+ "toolCallId": params.get("toolCallId"),
221
+ "result": {
222
+ "resultType": "failure",
223
+ "error": "Permission denied by user",
224
+ },
225
+ },
226
+ )
227
+ continue
228
+
229
+ yield StreamEvent(
230
+ type=AgentEvent.TOOL_CALL,
231
+ payload={
232
+ "name": params.get("toolName"),
233
+ "arguments": params.get("arguments"),
234
+ "id": req_id,
235
+ "toolCallId": params.get("toolCallId"),
236
+ },
237
+ )
238
+ continue
239
+
240
+ # Handle Notifications
241
+ method = notification.get("method")
242
+ params = notification.get("params", {})
243
+
244
+ if method == "session.event":
245
+ event = params.get("event", {})
246
+ event_type = event.get("type")
247
+ data = event.get("data", {})
248
+
249
+ if event_type == "assistant.message":
250
+ content = data.get("content", "")
251
+ if content:
252
+ yield StreamEvent(type=AgentEvent.CONTENT, payload=content)
253
+
254
+ elif event_type == "session.idle":
255
+ yield StreamEvent(type=AgentEvent.DONE, payload={})
256
+ break
257
+
258
+ elif event_type == "session.error":
259
+ yield StreamEvent(
260
+ type=AgentEvent.ERROR, payload=data.get("message")
261
+ )
262
+ break
263
+
264
+ elif method == "log":
265
+ yield StreamEvent(
266
+ type=AgentEvent.THOUGHT, payload=params.get("message")
267
+ )
268
+
269
+ async def send_tool_result(self, call_info: Any, result: Any) -> None:
270
+ """Official SDK requirement: send tool execution result back via RPC."""
271
+ # call_info is the payload from AgentEvent.TOOL_CALL
272
+ req_id = call_info.get("id")
273
+ tool_call_id = call_info.get("toolCallId")
274
+
275
+ # Official protocol nesting:
276
+ # { result: { toolCallId: ..., result: { resultType: success, ... } } }
277
+ wrapped_result = {
278
+ "toolCallId": tool_call_id,
279
+ "result": {"resultType": "success", "textResultForLlm": str(result)},
280
+ }
281
+ await self.client.send_response(req_id, result=wrapped_result)
@@ -0,0 +1,77 @@
1
+ from typing import Any, Dict, List, Optional
2
+
3
+ from ..core.types import AgentEvent, StreamEvent
4
+ from .cli_json_driver import CliJsonDriver
5
+
6
+
7
+ class GeminiDriver(CliJsonDriver):
8
+ """
9
+ Driver for Gemini CLI (The "Agentic" CLI).
10
+ Fully leverages the local 'gemini' binary as the runtime engine.
11
+ """
12
+
13
+ def __init__(
14
+ self, executable_path: str = "gemini", session_id: Optional[str] = None
15
+ ):
16
+ # We use -o stream-json to get machine-readable output from the CLI
17
+ super().__init__(
18
+ executable_path, base_args=["-o", "stream-json"], session_id=session_id
19
+ )
20
+
21
+ def _map_event(self, data: Dict[str, Any]) -> List[StreamEvent]:
22
+ """
23
+ Maps Gemini CLI's specific JSONL events to universal SDK events.
24
+ """
25
+ res = []
26
+ t = data.get("type")
27
+
28
+ if t == "init":
29
+ self.session_id = data.get("session_id")
30
+
31
+ elif t == "message":
32
+ if data.get("role") == "assistant" and data.get("content"):
33
+ res.append(
34
+ StreamEvent(type=AgentEvent.CONTENT, payload=data.get("content"))
35
+ )
36
+
37
+ elif t == "tool_use":
38
+ # Surfacing Gemini's internal tool usage as a THOUGHT or TOOL_CALL
39
+ # In Gemini CLI, the CLI executes the tool itself.
40
+ # We report it so the SDK user knows what's happening.
41
+ res.append(
42
+ StreamEvent(
43
+ type=AgentEvent.THOUGHT,
44
+ payload=f"Executing built-in tool: {data.get('tool_name')}",
45
+ )
46
+ )
47
+ res.append(
48
+ StreamEvent(
49
+ type=AgentEvent.TOOL_CALL,
50
+ payload={
51
+ "name": data.get("tool_name"),
52
+ "arguments": data.get("parameters"),
53
+ "id": data.get("tool_id"),
54
+ },
55
+ )
56
+ )
57
+
58
+ elif t == "tool_result":
59
+ # Report the result of the built-in tool
60
+ res.append(
61
+ StreamEvent(
62
+ type=AgentEvent.TOOL_RESULT,
63
+ payload={"id": data.get("tool_id"), "result": data.get("output")},
64
+ )
65
+ )
66
+
67
+ elif t == "result":
68
+ res.append(StreamEvent(type=AgentEvent.DONE, payload=None))
69
+
70
+ return res
71
+
72
+ async def send_tool_result(self, call_id: str, result: Any) -> None:
73
+ """
74
+ Gemini CLI handles its own tools. This is a no-op for the CLI driver.
75
+ (If we want custom Python tools, we'd use GeminiRestDriver).
76
+ """
77
+ pass
@@ -0,0 +1,80 @@
1
+ import asyncio
2
+ from typing import Any, AsyncGenerator, List
3
+
4
+ from ..core.driver import AgentDriver
5
+ from ..core.types import AgentEvent, Message, Role, StreamEvent, ToolDefinition
6
+
7
+
8
+ class MockDriver(AgentDriver):
9
+ """
10
+ A simple Echo driver for testing the SDK without a real CLI.
11
+ """
12
+
13
+ def __init__(self):
14
+ self.tools = []
15
+ self.system_prompt = ""
16
+
17
+ async def start(self) -> None:
18
+ print("[MockDriver] Starting...")
19
+
20
+ async def stop(self) -> None:
21
+ print("[MockDriver] Stopping...")
22
+
23
+ def set_tools(self, tools: List[ToolDefinition]) -> None:
24
+ self.tools = tools
25
+ print(f"[MockDriver] Registered {len(tools)} tools.")
26
+
27
+ def set_system_prompt(self, prompt: str) -> None:
28
+ self.system_prompt = prompt
29
+ print(f"[MockDriver] System prompt set: {prompt[:20]}...")
30
+
31
+ async def chat(self, messages: List[Message]) -> AsyncGenerator[StreamEvent, None]:
32
+ last_message = messages[-1]
33
+
34
+ # If the last message was a tool result, acknowledge it
35
+ if last_message.role == Role.TOOL:
36
+ yield StreamEvent(type=AgentEvent.START, payload=None)
37
+ yield StreamEvent(
38
+ type=AgentEvent.THOUGHT, payload="I received the tool output."
39
+ )
40
+ response = f"The tool result was: {last_message.content}"
41
+ yield StreamEvent(type=AgentEvent.CONTENT, payload=response)
42
+ yield StreamEvent(type=AgentEvent.DONE, payload=None)
43
+ return
44
+
45
+ content = last_message.content.lower()
46
+
47
+ yield StreamEvent(type=AgentEvent.START, payload=None)
48
+
49
+ # Simulate "thinking"
50
+ yield StreamEvent(type=AgentEvent.THOUGHT, payload="Processing request...")
51
+ await asyncio.sleep(0.2)
52
+
53
+ # Basic logic to simulate tool calling behavior
54
+ if "weather" in content:
55
+ # Simulate a tool call request from the agent
56
+ tool_call_id = "call_123"
57
+ yield StreamEvent(
58
+ type=AgentEvent.THOUGHT, payload="I need to check the weather."
59
+ )
60
+
61
+ # Yield a tool call event
62
+ # In a real driver, this comes from the model
63
+ yield StreamEvent(
64
+ type=AgentEvent.TOOL_CALL,
65
+ payload={
66
+ "id": tool_call_id,
67
+ "name": "get_weather",
68
+ "arguments": {"location": "New York"},
69
+ },
70
+ )
71
+ return
72
+
73
+ # Default Echo behavior
74
+ response = f"Echo: {last_message.content}"
75
+ yield StreamEvent(type=AgentEvent.CONTENT, payload=response)
76
+
77
+ yield StreamEvent(type=AgentEvent.DONE, payload=None)
78
+
79
+ async def send_tool_result(self, call_id: str, result: Any) -> None:
80
+ print(f"[MockDriver] Received tool result for {call_id}: {result}")
@@ -0,0 +1,178 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ from typing import Any, Dict, Optional
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ class JsonRpcClient:
10
+ """
11
+ A generic async JSON-RPC 2.0 client that communicates over stdio with a subprocess.
12
+ """
13
+
14
+ def __init__(
15
+ self, command: str, cwd: Optional[str] = None, env: Optional[dict] = None
16
+ ):
17
+ self.command = command
18
+ self.cwd = cwd
19
+ self.env = env
20
+ self.process: Optional[asyncio.subprocess.Process] = None
21
+ self._request_id = 0
22
+ self._pending_requests: Dict[int, asyncio.Future] = {}
23
+ self._read_task: Optional[asyncio.Task] = None
24
+ self._notification_queue = asyncio.Queue()
25
+
26
+ async def start(self):
27
+ """Starts the subprocess."""
28
+ self.process = await asyncio.create_subprocess_shell(
29
+ self.command,
30
+ stdin=asyncio.subprocess.PIPE,
31
+ stdout=asyncio.subprocess.PIPE,
32
+ stderr=asyncio.subprocess.PIPE,
33
+ cwd=self.cwd,
34
+ env=self.env,
35
+ )
36
+ self._read_task = asyncio.create_task(self._read_loop())
37
+ logger.info(f"Started JSON-RPC process: {self.command}")
38
+
39
+ async def stop(self):
40
+ """Stops the subprocess."""
41
+ if self.process:
42
+ if self.process.returncode is None:
43
+ self.process.terminate()
44
+ try:
45
+ await asyncio.wait_for(self.process.wait(), timeout=2.0)
46
+ except asyncio.TimeoutError:
47
+ self.process.kill()
48
+ self.process = None
49
+
50
+ if self._read_task:
51
+ self._read_task.cancel()
52
+ try:
53
+ await self._read_task
54
+ except asyncio.CancelledError:
55
+ pass
56
+
57
+ async def request(self, method: str, params: Any = None) -> Any:
58
+ """Sends a JSON-RPC request and awaits the result."""
59
+ if not self.process:
60
+ raise RuntimeError("Process not running")
61
+
62
+ req_id = self._request_id
63
+ self._request_id += 1
64
+
65
+ payload = {"jsonrpc": "2.0", "method": method, "params": params, "id": req_id}
66
+
67
+ future = asyncio.get_running_loop().create_future()
68
+ self._pending_requests[req_id] = future
69
+
70
+ await self._send(payload)
71
+ return await future
72
+
73
+ async def notify(self, method: str, params: Any = None):
74
+ """Sends a JSON-RPC notification (no response expected)."""
75
+ payload = {"jsonrpc": "2.0", "method": method, "params": params}
76
+ await self._send(payload)
77
+
78
+ async def get_notification(self) -> Dict[str, Any]:
79
+ """Waits for the next notification from the server."""
80
+ return await self._notification_queue.get()
81
+
82
+ async def _send(self, payload: Dict[str, Any]):
83
+ json_body = json.dumps(payload).encode("utf-8")
84
+ # LSP-style framing: Content-Length header + \r\n\r\n + body
85
+ header = f"Content-Length: {len(json_body)}\r\n\r\n".encode("ascii")
86
+
87
+ print(f"[JsonRpc] Sending: {payload}")
88
+ if self.process and self.process.stdin:
89
+ self.process.stdin.write(header + json_body)
90
+ await self.process.stdin.drain()
91
+
92
+ async def _read_loop(self):
93
+ """Reads stdout using Hybrid framing (LSP or NDJSON)."""
94
+ if not self.process or not self.process.stdout:
95
+ return
96
+
97
+ try:
98
+ while True:
99
+ line = await self.process.stdout.readline()
100
+ if not line:
101
+ logger.info("[JsonRpc] EOF reached")
102
+ break
103
+
104
+ line_str = line.decode("utf-8", errors="ignore").strip()
105
+ if not line_str:
106
+ continue
107
+
108
+ # 1. Check for LSP Header
109
+ if line_str.lower().startswith("content-length:"):
110
+ try:
111
+ content_length = int(line_str.split(":", 1)[1].strip())
112
+ # Skip the following empty line (\r\n)
113
+ await self.process.stdout.readline()
114
+ # Read exact body
115
+ body_bytes = await self.process.stdout.readexactly(
116
+ content_length
117
+ )
118
+ message = json.loads(body_bytes.decode("utf-8"))
119
+ self._handle_message(message)
120
+ except Exception as e:
121
+ logger.error(f"[JsonRpc] Error parsing LSP: {e}")
122
+ continue
123
+
124
+ # 2. Check for NDJSON (Line starting with {)
125
+ if line_str.startswith("{"):
126
+ try:
127
+ message = json.loads(line_str)
128
+ self._handle_message(message)
129
+ except json.JSONDecodeError:
130
+ logger.warning(f"[JsonRpc] Failed to decode NDJSON: {line_str}")
131
+ continue
132
+
133
+ # 3. Otherwise, treat as log or noise
134
+ logger.debug(f"[JsonRpc] Log/Noise: {line_str}")
135
+ except asyncio.CancelledError:
136
+ pass
137
+ except Exception as e:
138
+ logger.error(f"[JsonRpc] Read loop error: {e}")
139
+ finally:
140
+ # Cleanup pending requests to avoid permanent hangs
141
+ for _, future in self._pending_requests.items():
142
+ if not future.done():
143
+ future.set_exception(RuntimeError("Connection closed"))
144
+ self._pending_requests.clear()
145
+ # Also put a sentinel in notification queue if needed?
146
+ # Better to let the consumer handle the closed process.
147
+
148
+ def _handle_message(self, message: Dict[str, Any]):
149
+ print(f"[JsonRpc] Internal handle: {message}")
150
+ if "id" in message:
151
+ req_id = message["id"]
152
+ if req_id in self._pending_requests:
153
+ # It's a response to OUR request
154
+ future = self._pending_requests.pop(req_id)
155
+ if "error" in message:
156
+ future.set_exception(Exception(message["error"]))
157
+ else:
158
+ future.set_result(message.get("result"))
159
+ else:
160
+ # It's a request FROM the server (Client-side tool execution)
161
+ # We need to handle this. For now, we put it in the notification queue
162
+ # but mark it as a request so the consumer knows to reply.
163
+ self._notification_queue.put_nowait(
164
+ {"type": "server_request", "payload": message}
165
+ )
166
+ else:
167
+ # It's a notification or log
168
+ self._notification_queue.put_nowait(message)
169
+
170
+ async def send_response(self, req_id: Any, result: Any = None, error: Any = None):
171
+ """Sends a JSON-RPC response back to the server."""
172
+ payload = {"jsonrpc": "2.0", "id": req_id}
173
+ if error:
174
+ payload["error"] = error
175
+ else:
176
+ payload["result"] = result
177
+
178
+ await self._send(payload)
@@ -0,0 +1,48 @@
1
+ import inspect
2
+ from typing import Any, Callable, Dict, get_type_hints
3
+
4
+
5
+ def python_type_to_json_type(py_type: Any) -> str:
6
+ if py_type is str:
7
+ return "string"
8
+ elif py_type is int:
9
+ return "integer"
10
+ elif py_type is float:
11
+ return "number"
12
+ elif py_type is bool:
13
+ return "boolean"
14
+ elif py_type is list:
15
+ return "array"
16
+ elif py_type is dict:
17
+ return "object"
18
+ # Default fallback
19
+ return "string"
20
+
21
+
22
+ def generate_tool_schema(func: Callable) -> Dict[str, Any]:
23
+ """
24
+ Generates a JSON Schema for a Python function's arguments.
25
+ Follows the OpenAI/Gemini tool definition convention.
26
+ """
27
+ sig = inspect.signature(func)
28
+ type_hints = get_type_hints(func)
29
+
30
+ properties = {}
31
+ required = []
32
+
33
+ for param_name, param in sig.parameters.items():
34
+ if param_name == "self" or param_name == "cls":
35
+ continue
36
+
37
+ param_type = type_hints.get(param_name, str) # Default to string if no hint
38
+ json_type = python_type_to_json_type(param_type)
39
+
40
+ properties[param_name] = {
41
+ "type": json_type,
42
+ "description": f"Parameter {param_name}", # Ideally parse from docstring
43
+ }
44
+
45
+ if param.default == inspect.Parameter.empty:
46
+ required.append(param_name)
47
+
48
+ return {"type": "object", "properties": properties, "required": required}