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.
- agent_cli_sdk-0.0.1a1.dist-info/METADATA +157 -0
- agent_cli_sdk-0.0.1a1.dist-info/RECORD +14 -0
- agent_cli_sdk-0.0.1a1.dist-info/WHEEL +4 -0
- agent_cli_sdk-0.0.1a1.dist-info/licenses/LICENSE +21 -0
- agent_sdk/__init__.py +24 -0
- agent_sdk/core/agent.py +124 -0
- agent_sdk/core/driver.py +46 -0
- agent_sdk/core/types.py +46 -0
- agent_sdk/drivers/cli_json_driver.py +116 -0
- agent_sdk/drivers/copilot_driver.py +281 -0
- agent_sdk/drivers/gemini_driver.py +77 -0
- agent_sdk/drivers/mock_driver.py +80 -0
- agent_sdk/utils/jsonrpc.py +178 -0
- agent_sdk/utils/schema.py +48 -0
|
@@ -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
|
+
[](https://github.com/rbbtsn0w/agent-cli-sdk/actions)
|
|
56
|
+
[](https://opensource.org/licenses/MIT)
|
|
57
|
+
[](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,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
|
+
]
|
agent_sdk/core/agent.py
ADDED
|
@@ -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
|
+
}
|
agent_sdk/core/driver.py
ADDED
|
@@ -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
|
agent_sdk/core/types.py
ADDED
|
@@ -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}
|