agno-cursor-agent 0.1.0__tar.gz

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,17 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .coverage
6
+ coverage.xml
7
+ htmlcov/
8
+ *.egg-info/
9
+ dist/
10
+ build/
11
+
12
+ .env
13
+ .env.local
14
+ config.yaml
15
+ *.db
16
+
17
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 vaiden
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.
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.5
2
+ Name: agno-cursor-agent
3
+ Version: 0.1.0
4
+ Summary: Agno Model adapter for persistent Cursor CLI ACP sessions
5
+ Project-URL: Homepage, https://github.com/vaiden/agno-cursor-agent
6
+ Project-URL: Repository, https://github.com/vaiden/agno-cursor-agent
7
+ Author: vaiden
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: a2a,acp,agent,agno,cursor
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Software Development :: Libraries
18
+ Requires-Python: >=3.10
19
+ Requires-Dist: agno[a2a]>=2.0.0
20
+ Requires-Dist: httpx>=0.27.0
21
+ Requires-Dist: pydantic-settings>=2.0.0
22
+ Requires-Dist: pyyaml>=6.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: fastapi>=0.100.0; extra == 'dev'
25
+ Requires-Dist: httpx>=0.27.0; extra == 'dev'
26
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
27
+ Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
28
+ Requires-Dist: pytest-mock>=3.0.0; extra == 'dev'
29
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
30
+ Requires-Dist: sqlalchemy>=2.0.0; extra == 'dev'
31
+ Provides-Extra: standard
32
+ Requires-Dist: uvicorn[standard]>=0.27.0; extra == 'standard'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # agno-cursor-agent
36
+
37
+ ![CI](https://github.com/vaiden/agno-cursor-agent/actions/workflows/ci.yml/badge.svg)
38
+ ![codecov](https://codecov.io/gh/vaiden/agno-cursor-agent/graph/badge.svg)
39
+
40
+ Agno `Model` adapter that talks to the **Cursor CLI** over a persistent **ACP** (JSON-RPC) session.
41
+
42
+ ## Reasoning
43
+ Cursor unlocks multiple exciting features: multiple model providers, cost router, agentic memory, code indexing, cloud agent provisioning and a powerful harness.
44
+ Now available to power your Agno agents for the best aSDLC pipeline experience.
45
+
46
+ ## Quickstart
47
+
48
+ Install from PyPI:
49
+
50
+ ```bash
51
+ pip install agno-cursor-agent
52
+ # AgentOS with WebSockets:
53
+ pip install "agno-cursor-agent[standard]"
54
+ ```
55
+
56
+ Import name on PyPI is **`agno-cursor-agent`**; in Python you still `import agno_cursor`.
57
+
58
+ ## Quick usage
59
+
60
+ ```python
61
+ from agno.agent import Agent
62
+ from agno_cursor import CursorACPChat, HarnessSettings
63
+
64
+ settings = HarnessSettings(acp_trace=False)
65
+ model = CursorACPChat(agent_key="coder", model="composer-2.5", mode="ask", settings=settings)
66
+
67
+ agent = Agent(id="coder", model=model, instructions=["You write code."])
68
+ # Requires cursor-agent login on the machine running this code.
69
+ ```
70
+
71
+ Harness environment variables use the `AGNO_CURSOR_` prefix (for example `AGNO_CURSOR_ACP_TRACE=true`).
72
+
73
+ ## Demo team
74
+
75
+ A full **Agno AgentOS + A2A** team lives under [`examples/demo_team`](examples/demo_team/README.md). It uses `CURSOR_TEAM_`* settings and role-based models (leader defaults to `auto`).
76
+
77
+ ```bash
78
+ cd examples/demo_team
79
+ pip install -e "../..[standard]"
80
+ cursor-agent login
81
+ python team_os.py
82
+ ```
83
+
84
+ Connect the Agno UI to **http://localhost:7777** (not `0.0.0.0`).
85
+
86
+ ## Development
87
+
88
+ - Unit tests mock ACP I/O; CI never calls `cursor-agent`.
89
+ - Integration tests: `pytest -m integration` locally with login and optional `team_os` running.
90
+
91
+ See [CONTRIBUTING.md](CONTRIBUTING.md).
@@ -0,0 +1,57 @@
1
+ # agno-cursor-agent
2
+
3
+ ![CI](https://github.com/vaiden/agno-cursor-agent/actions/workflows/ci.yml/badge.svg)
4
+ ![codecov](https://codecov.io/gh/vaiden/agno-cursor-agent/graph/badge.svg)
5
+
6
+ Agno `Model` adapter that talks to the **Cursor CLI** over a persistent **ACP** (JSON-RPC) session.
7
+
8
+ ## Reasoning
9
+ Cursor unlocks multiple exciting features: multiple model providers, cost router, agentic memory, code indexing, cloud agent provisioning and a powerful harness.
10
+ Now available to power your Agno agents for the best aSDLC pipeline experience.
11
+
12
+ ## Quickstart
13
+
14
+ Install from PyPI:
15
+
16
+ ```bash
17
+ pip install agno-cursor-agent
18
+ # AgentOS with WebSockets:
19
+ pip install "agno-cursor-agent[standard]"
20
+ ```
21
+
22
+ Import name on PyPI is **`agno-cursor-agent`**; in Python you still `import agno_cursor`.
23
+
24
+ ## Quick usage
25
+
26
+ ```python
27
+ from agno.agent import Agent
28
+ from agno_cursor import CursorACPChat, HarnessSettings
29
+
30
+ settings = HarnessSettings(acp_trace=False)
31
+ model = CursorACPChat(agent_key="coder", model="composer-2.5", mode="ask", settings=settings)
32
+
33
+ agent = Agent(id="coder", model=model, instructions=["You write code."])
34
+ # Requires cursor-agent login on the machine running this code.
35
+ ```
36
+
37
+ Harness environment variables use the `AGNO_CURSOR_` prefix (for example `AGNO_CURSOR_ACP_TRACE=true`).
38
+
39
+ ## Demo team
40
+
41
+ A full **Agno AgentOS + A2A** team lives under [`examples/demo_team`](examples/demo_team/README.md). It uses `CURSOR_TEAM_`* settings and role-based models (leader defaults to `auto`).
42
+
43
+ ```bash
44
+ cd examples/demo_team
45
+ pip install -e "../..[standard]"
46
+ cursor-agent login
47
+ python team_os.py
48
+ ```
49
+
50
+ Connect the Agno UI to **http://localhost:7777** (not `0.0.0.0`).
51
+
52
+ ## Development
53
+
54
+ - Unit tests mock ACP I/O; CI never calls `cursor-agent`.
55
+ - Integration tests: `pytest -m integration` locally with login and optional `team_os` running.
56
+
57
+ See [CONTRIBUTING.md](CONTRIBUTING.md).
@@ -0,0 +1,70 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "agno-cursor-agent"
7
+ version = "0.1.0"
8
+ description = "Agno Model adapter for persistent Cursor CLI ACP sessions"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "vaiden" }]
13
+ keywords = ["agno", "cursor", "acp", "agent", "a2a"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Topic :: Software Development :: Libraries",
22
+ ]
23
+ dependencies = [
24
+ "agno[a2a]>=2.0.0",
25
+ "pydantic-settings>=2.0.0",
26
+ "pyyaml>=6.0",
27
+ "httpx>=0.27.0",
28
+ ]
29
+
30
+ [project.optional-dependencies]
31
+ standard = ["uvicorn[standard]>=0.27.0"]
32
+ dev = [
33
+ "pytest>=8.0.0",
34
+ "pytest-asyncio>=0.23.0",
35
+ "pytest-cov>=4.0.0",
36
+ "pytest-mock>=3.0.0",
37
+ "httpx>=0.27.0",
38
+ "fastapi>=0.100.0",
39
+ "sqlalchemy>=2.0.0",
40
+ ]
41
+
42
+ [project.urls]
43
+ Homepage = "https://github.com/vaiden/agno-cursor-agent"
44
+ Repository = "https://github.com/vaiden/agno-cursor-agent"
45
+
46
+ [tool.hatch.build.targets.wheel]
47
+ only-include = ["agno_cursor"]
48
+
49
+ [tool.hatch.build.targets.wheel.sources]
50
+ "src/agno_cursor" = "agno_cursor"
51
+
52
+ [tool.hatch.build.targets.sdist]
53
+ include = ["/src", "/README.md", "/LICENSE"]
54
+
55
+ [tool.pytest.ini_options]
56
+ testpaths = ["tests", "examples/demo_team/tests"]
57
+ pythonpath = ["src", "examples/demo_team"]
58
+ addopts = ["-m", "not integration", "--strict-markers"]
59
+ asyncio_mode = "auto"
60
+ markers = [
61
+ "integration: tests requiring cursor-agent login and live ACP/AgentOS (opt-in with -m integration)",
62
+ ]
63
+
64
+ [tool.coverage.run]
65
+ source = ["agno_cursor"]
66
+ branch = true
67
+
68
+ [tool.coverage.report]
69
+ fail_under = 85
70
+ show_missing = true
@@ -0,0 +1,27 @@
1
+ """Agno + Cursor CLI ACP harness."""
2
+
3
+ from agno_cursor.acp.registry import ACPSessionRegistry, REGISTRY
4
+ from agno_cursor.acp.session import ACPSession
5
+ from agno_cursor.model.cursor_acp import CursorACPChat
6
+ from agno_cursor.settings import (
7
+ AcpMode,
8
+ HarnessSettings,
9
+ build_spawn_argv,
10
+ check_cursor_login,
11
+ )
12
+ from agno_cursor.trace import log_agent_block, log_agent_meta
13
+
14
+ __all__ = [
15
+ "ACPSession",
16
+ "ACPSessionRegistry",
17
+ "AcpMode",
18
+ "CursorACPChat",
19
+ "HarnessSettings",
20
+ "REGISTRY",
21
+ "build_spawn_argv",
22
+ "check_cursor_login",
23
+ "log_agent_block",
24
+ "log_agent_meta",
25
+ ]
26
+
27
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from agno_cursor.acp.registry import ACPSessionRegistry, REGISTRY
2
+ from agno_cursor.acp.session import ACPSession
3
+
4
+ __all__ = ["ACPSession", "ACPSessionRegistry", "REGISTRY"]
@@ -0,0 +1,53 @@
1
+ """Shared in-process ACP session registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from typing import Dict, List, Tuple
7
+
8
+ from agno_cursor.acp.session import ACPSession, PopenFactory
9
+ from agno_cursor.settings import ACPSettings, AcpMode
10
+
11
+
12
+ class ACPSessionRegistry:
13
+ def __init__(self) -> None:
14
+ self._sessions: Dict[str, ACPSession] = {}
15
+ self._lock = threading.Lock()
16
+ self._popen_factory: PopenFactory | None = None
17
+
18
+ def set_popen_factory(self, factory: PopenFactory | None) -> None:
19
+ self._popen_factory = factory
20
+
21
+ def get_or_create(
22
+ self,
23
+ agent_key: str,
24
+ model: str,
25
+ mode: AcpMode,
26
+ settings: ACPSettings,
27
+ ) -> ACPSession:
28
+ with self._lock:
29
+ session = self._sessions.get(agent_key)
30
+ if session is None or session._closed:
31
+ session = ACPSession.from_settings(
32
+ agent_key,
33
+ model,
34
+ mode,
35
+ settings,
36
+ popen_factory=self._popen_factory,
37
+ )
38
+ session.ensure_started()
39
+ self._sessions[agent_key] = session
40
+ return session
41
+
42
+ def warm(self, keys: List[Tuple[str, str, AcpMode]], settings: ACPSettings) -> None:
43
+ for agent_key, model, mode in keys:
44
+ self.get_or_create(agent_key, model, mode, settings)
45
+
46
+ def close_all(self) -> None:
47
+ with self._lock:
48
+ for session in self._sessions.values():
49
+ session.close()
50
+ self._sessions.clear()
51
+
52
+
53
+ REGISTRY = ACPSessionRegistry()
@@ -0,0 +1,251 @@
1
+ """Persistent ACP session bridge to cursor-agent acp (JSON-RPC over stdio)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import subprocess
8
+ import threading
9
+ import time
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Callable, Dict, List, Optional
12
+
13
+ from agno_cursor.settings import ACPSettings, AcpMode, resolve_acp_role
14
+ from agno_cursor.trace import log_agent_block
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ _TRACE_PREVIEW = 1500
19
+
20
+ PopenFactory = Callable[..., subprocess.Popen[str]]
21
+
22
+
23
+ def _trace_preview(text: str, limit: int = _TRACE_PREVIEW) -> str:
24
+ if len(text) <= limit:
25
+ return text
26
+ return f"{text[:limit]}… ({len(text)} chars total)"
27
+
28
+
29
+ @dataclass
30
+ class ACPSession:
31
+ agent_key: str
32
+ model: str
33
+ mode: AcpMode
34
+ cwd: str
35
+ spawn_argv: List[str]
36
+ trace: bool = False
37
+ role: str = ""
38
+ popen_factory: PopenFactory = field(default=subprocess.Popen, repr=False)
39
+
40
+ _proc: Optional[subprocess.Popen[str]] = field(default=None, init=False, repr=False)
41
+ _session_id: Optional[str] = field(default=None, init=False, repr=False)
42
+ _req_id: int = field(default=0, init=False, repr=False)
43
+ _pending: Dict[int, Any] = field(default_factory=dict, init=False, repr=False)
44
+ _lock: threading.RLock = field(default_factory=threading.RLock, init=False, repr=False)
45
+ _io_lock: threading.RLock = field(default_factory=threading.RLock, init=False, repr=False)
46
+ _reader_thread: Optional[threading.Thread] = field(default=None, init=False, repr=False)
47
+ _stderr_thread: Optional[threading.Thread] = field(default=None, init=False, repr=False)
48
+ _stderr_tail: List[str] = field(default_factory=list, init=False, repr=False)
49
+ _active_chunks: List[str] = field(default_factory=list, init=False, repr=False)
50
+ _initialized: bool = field(default=False, init=False, repr=False)
51
+ _closed: bool = field(default=False, init=False, repr=False)
52
+
53
+ @classmethod
54
+ def from_settings(
55
+ cls,
56
+ agent_key: str,
57
+ model: str,
58
+ mode: AcpMode,
59
+ settings: ACPSettings,
60
+ *,
61
+ popen_factory: PopenFactory | None = None,
62
+ ) -> "ACPSession":
63
+ return cls(
64
+ agent_key=agent_key,
65
+ model=model,
66
+ mode=mode,
67
+ cwd=str(settings.project_root),
68
+ spawn_argv=settings.spawn_argv(model, mode),
69
+ trace=settings.acp_trace,
70
+ role=resolve_acp_role(settings, agent_key),
71
+ popen_factory=popen_factory or subprocess.Popen,
72
+ )
73
+
74
+ @property
75
+ def argv(self) -> List[str]:
76
+ return list(self.spawn_argv)
77
+
78
+ def ensure_started(self) -> None:
79
+ with self._lock:
80
+ if self._closed:
81
+ raise RuntimeError(f"ACP session {self.agent_key} is closed")
82
+ if self._proc is None or self._proc.poll() is not None:
83
+ self._start_process()
84
+ if not self._initialized:
85
+ self._initialize_session()
86
+ self._initialized = True
87
+
88
+ def _start_process(self) -> None:
89
+ logger.info("Starting ACP process for %s: %s", self.agent_key, self.spawn_argv)
90
+ self._stderr_tail = []
91
+ self._proc = self.popen_factory(
92
+ self.spawn_argv,
93
+ stdin=subprocess.PIPE,
94
+ stdout=subprocess.PIPE,
95
+ stderr=subprocess.PIPE,
96
+ text=True,
97
+ bufsize=1,
98
+ cwd=self.cwd,
99
+ )
100
+ self._pending.clear()
101
+ self._reader_thread = threading.Thread(
102
+ target=self._read_loop, daemon=True, name=f"acp-{self.agent_key}"
103
+ )
104
+ self._reader_thread.start()
105
+ self._stderr_thread = threading.Thread(
106
+ target=self._stderr_loop, daemon=True, name=f"acp-err-{self.agent_key}"
107
+ )
108
+ self._stderr_thread.start()
109
+
110
+ def _stderr_loop(self) -> None:
111
+ assert self._proc and self._proc.stderr
112
+ for line in self._proc.stderr:
113
+ line = line.rstrip("\n")
114
+ if not line:
115
+ continue
116
+ self._stderr_tail.append(line)
117
+ if len(self._stderr_tail) > 20:
118
+ self._stderr_tail.pop(0)
119
+
120
+ def _read_loop(self) -> None:
121
+ assert self._proc and self._proc.stdout
122
+ for line in self._proc.stdout:
123
+ line = line.strip()
124
+ if not line:
125
+ continue
126
+ try:
127
+ msg = json.loads(line)
128
+ except json.JSONDecodeError:
129
+ logger.debug("ACP non-JSON stdout: %s", line[:200])
130
+ continue
131
+ self._handle_message(msg)
132
+
133
+ def _handle_message(self, msg: Dict[str, Any]) -> None:
134
+ if "id" in msg and ("result" in msg or "error" in msg):
135
+ with self._io_lock:
136
+ self._pending[msg["id"]] = msg
137
+ return
138
+
139
+ method = msg.get("method")
140
+ if method == "session/update":
141
+ update = msg.get("params", {}).get("update", {})
142
+ if update.get("sessionUpdate") == "agent_message_chunk":
143
+ text = update.get("content", {}).get("text", "")
144
+ if text:
145
+ self._active_chunks.append(text)
146
+ return
147
+
148
+ if method == "session/request_permission":
149
+ req_id = msg.get("id")
150
+ if req_id is not None:
151
+ self._send_raw(
152
+ {
153
+ "jsonrpc": "2.0",
154
+ "id": req_id,
155
+ "result": {"outcome": {"outcome": "selected", "optionId": "allow-once"}},
156
+ }
157
+ )
158
+ return
159
+
160
+ logger.debug("ACP notification %s: %s", method, str(msg)[:200])
161
+
162
+ def _send_raw(self, payload: Dict[str, Any]) -> None:
163
+ assert self._proc and self._proc.stdin
164
+ with self._io_lock:
165
+ self._proc.stdin.write(json.dumps(payload) + "\n")
166
+ self._proc.stdin.flush()
167
+
168
+ def _call(self, method: str, params: Dict[str, Any], timeout: float = 120.0) -> Dict[str, Any]:
169
+ with self._io_lock:
170
+ self._req_id += 1
171
+ req_id = self._req_id
172
+ self._send_raw({"jsonrpc": "2.0", "id": req_id, "method": method, "params": params})
173
+
174
+ deadline = time.time() + timeout
175
+ while time.time() < deadline:
176
+ with self._io_lock:
177
+ if req_id in self._pending:
178
+ msg = self._pending.pop(req_id)
179
+ if "error" in msg:
180
+ raise RuntimeError(f"ACP {method} error: {msg['error']}")
181
+ return msg.get("result", {})
182
+ if self._proc and self._proc.poll() is not None:
183
+ exit_code = self._proc.poll()
184
+ tail = list(self._stderr_tail)
185
+ detail = tail[-1] if tail else f"exit code {exit_code}"
186
+ raise RuntimeError(f"ACP process died during {method}: {detail}")
187
+ time.sleep(0.02)
188
+ raise TimeoutError(f"ACP call timed out: {method}")
189
+
190
+ def _initialize_session(self) -> None:
191
+ self._call(
192
+ "initialize",
193
+ {
194
+ "protocolVersion": 1,
195
+ "clientCapabilities": {
196
+ "fs": {"readTextFile": False, "writeTextFile": False},
197
+ "terminal": False,
198
+ },
199
+ "clientInfo": {"name": "agno-cursor-acp", "version": "0.1.0"},
200
+ },
201
+ )
202
+ self._call("authenticate", {"methodId": "cursor_login"})
203
+ result = self._call("session/new", {"cwd": self.cwd, "mcpServers": []})
204
+ self._session_id = result["sessionId"]
205
+ logger.info("ACP session ready for %s: %s", self.agent_key, self._session_id)
206
+
207
+ def prompt(self, text: str, timeout: float = 180.0) -> str:
208
+ with self._lock:
209
+ self.ensure_started()
210
+ assert self._session_id
211
+ if self.trace:
212
+ log_agent_block(
213
+ role=self.role,
214
+ model=self.model,
215
+ agent_key=self.agent_key,
216
+ mode=self.mode,
217
+ label=f"ACP → Cursor (session={self._session_id})",
218
+ body=_trace_preview(text),
219
+ )
220
+ self._active_chunks = []
221
+ self._call(
222
+ "session/prompt",
223
+ {
224
+ "sessionId": self._session_id,
225
+ "prompt": [{"type": "text", "text": text}],
226
+ },
227
+ timeout=timeout,
228
+ )
229
+ reply = "".join(self._active_chunks).strip()
230
+ if self.trace:
231
+ log_agent_block(
232
+ role=self.role,
233
+ model=self.model,
234
+ agent_key=self.agent_key,
235
+ mode=self.mode,
236
+ label="ACP ← Cursor",
237
+ body=_trace_preview(reply) if reply else "(empty)",
238
+ )
239
+ return reply
240
+
241
+ def close(self) -> None:
242
+ with self._lock:
243
+ self._closed = True
244
+ if self._proc and self._proc.poll() is None:
245
+ try:
246
+ self._proc.terminate()
247
+ self._proc.wait(timeout=5)
248
+ except Exception:
249
+ self._proc.kill()
250
+ self._proc = None
251
+ self._initialized = False
@@ -0,0 +1,3 @@
1
+ from agno_cursor.model.cursor_acp import CursorACPChat
2
+
3
+ __all__ = ["CursorACPChat"]
@@ -0,0 +1,264 @@
1
+ """Custom Agno Model adapter: Agno model API <-> Cursor ACP."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import re
8
+ import uuid
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, AsyncIterator, Dict, Iterator, List, Optional, Type, Union
11
+
12
+ from pydantic import BaseModel
13
+
14
+ from agno.models.base import Model
15
+ from agno.models.message import Message
16
+ from agno.models.response import ModelResponse
17
+ from agno.run.agent import RunOutput
18
+ from agno.run.team import TeamRunOutput
19
+
20
+ from agno_cursor.acp.registry import REGISTRY
21
+ from agno_cursor.acp.session import ACPSession
22
+ from agno_cursor.settings import AcpMode, HarnessSettings
23
+ from agno_cursor.trace import log_agent_meta
24
+
25
+ TOOL_CALL_INSTRUCTION = """
26
+ When tools are available you MUST respond with ONLY valid JSON (no markdown fences):
27
+ {{"tool_calls":[{{"name":"<tool_name>","arguments":{{...}}}}]}}
28
+ If no tool is needed respond with ONLY:
29
+ {{"content":"<your reply>"}}
30
+ Available tools (name and parameters):
31
+ {tools_json}
32
+ """
33
+
34
+
35
+ @dataclass
36
+ class CursorACPChat(Model):
37
+ id: str = "composer-2.5"
38
+ agent_key: str = ""
39
+ model: str = "composer-2.5"
40
+ mode: AcpMode = "ask"
41
+ settings: Any = field(default=None, repr=False)
42
+ provider: str = "cursor"
43
+ _first_turn: bool = field(default=True, init=False, repr=False)
44
+
45
+ def __post_init__(self) -> None:
46
+ self.id = self.model
47
+ if not self.agent_key:
48
+ self.agent_key = self.model
49
+ if self.settings is None:
50
+ self.settings = HarnessSettings()
51
+
52
+ def _session(self) -> ACPSession:
53
+ return REGISTRY.get_or_create(self.agent_key, self.model, self.mode, self.settings)
54
+
55
+ def _tools_prompt_json(self, tools: Optional[List[Any]]) -> str:
56
+ if not tools:
57
+ return "[]"
58
+ simplified = []
59
+ for t in tools:
60
+ if hasattr(t, "to_dict") and callable(t.to_dict):
61
+ fn = t.to_dict()
62
+ if "name" in fn:
63
+ simplified.append(
64
+ {
65
+ "name": fn.get("name"),
66
+ "description": fn.get("description"),
67
+ "parameters": fn.get("parameters"),
68
+ }
69
+ )
70
+ continue
71
+ if isinstance(t, dict):
72
+ fn = t.get("function", t)
73
+ simplified.append(
74
+ {
75
+ "name": fn.get("name"),
76
+ "description": fn.get("description"),
77
+ "parameters": fn.get("parameters"),
78
+ }
79
+ )
80
+ return json.dumps(simplified, indent=2)
81
+
82
+ def _build_prompt(
83
+ self,
84
+ messages: List[Message],
85
+ tools: Optional[List[Dict[str, Any]]] = None,
86
+ ) -> str:
87
+ parts: List[str] = []
88
+ if self._first_turn:
89
+ system_msgs = [
90
+ m.get_content_string() for m in messages if m.role == "system" and m.get_content_string()
91
+ ]
92
+ if system_msgs:
93
+ parts.append("System instructions:\n" + "\n".join(system_msgs))
94
+
95
+ for m in reversed(messages):
96
+ if m.role == "user" and m.get_content_string():
97
+ parts.append(m.get_content_string())
98
+ break
99
+ if m.role == "tool" and m.get_content_string():
100
+ parts.append("Tool result:\n" + m.get_content_string())
101
+ break
102
+
103
+ if not parts:
104
+ parts.append(messages[-1].get_content_string() if messages else "")
105
+
106
+ prompt = parts[-1] if len(parts) == 1 and not self._first_turn else "\n\n".join(parts)
107
+
108
+ if tools:
109
+ prompt += "\n\n" + TOOL_CALL_INSTRUCTION.format(tools_json=self._tools_prompt_json(tools))
110
+
111
+ self._first_turn = False
112
+ return prompt
113
+
114
+ def _extract_json(self, text: str) -> Optional[Dict[str, Any]]:
115
+ text = text.strip()
116
+ if text.startswith("{") and text.endswith("}"):
117
+ try:
118
+ return json.loads(text)
119
+ except json.JSONDecodeError:
120
+ pass
121
+ match = re.search(r"\{[\s\S]*\}", text)
122
+ if match:
123
+ try:
124
+ return json.loads(match.group())
125
+ except json.JSONDecodeError:
126
+ return None
127
+ return None
128
+
129
+ def _parse_provider_response(self, response: Any, **kwargs) -> ModelResponse:
130
+ text = response if isinstance(response, str) else str(response)
131
+ parsed = self._extract_json(text)
132
+ model_response = ModelResponse(role="assistant")
133
+
134
+ if parsed and "tool_calls" in parsed:
135
+ tool_calls = []
136
+ for tc in parsed["tool_calls"]:
137
+ name = tc.get("name")
138
+ args = tc.get("arguments", {})
139
+ tool_calls.append(
140
+ {
141
+ "id": f"call_{uuid.uuid4().hex[:12]}",
142
+ "type": "function",
143
+ "function": {
144
+ "name": name,
145
+ "arguments": json.dumps(args) if isinstance(args, dict) else str(args),
146
+ },
147
+ }
148
+ )
149
+ model_response.tool_calls = tool_calls
150
+ return model_response
151
+
152
+ if parsed and "content" in parsed:
153
+ model_response.content = parsed["content"]
154
+ return model_response
155
+
156
+ model_response.content = text
157
+ return model_response
158
+
159
+ def _parse_provider_response_delta(self, response: Any) -> ModelResponse:
160
+ return self._parse_provider_response(response)
161
+
162
+ def invoke(
163
+ self,
164
+ messages: List[Message],
165
+ assistant_message: Message,
166
+ response_format: Optional[Union[Dict, Type[BaseModel]]] = None,
167
+ tools: Optional[List[Dict[str, Any]]] = None,
168
+ tool_choice: Optional[Union[str, Dict[str, Any]]] = None,
169
+ run_response: Optional[Union[RunOutput, TeamRunOutput]] = None,
170
+ compress_tool_results: bool = False,
171
+ ) -> ModelResponse:
172
+ prompt = self._build_prompt(messages, tools=tools)
173
+ resolved = self.settings.resolve(self.agent_key, self.agent_key) if hasattr(self.settings, "resolve") else None
174
+ role = resolved.role if resolved else self.agent_key
175
+ mode = resolved.mode if resolved else self.mode
176
+
177
+ if getattr(self.settings, "acp_trace", False):
178
+ log_agent_meta(
179
+ role=role,
180
+ model=self.model,
181
+ agent_key=self.agent_key,
182
+ mode=mode,
183
+ label="Agno → ACP (invoke)",
184
+ detail=f"tools={'yes' if tools else 'no'}",
185
+ )
186
+ raw = self._session().prompt(prompt)
187
+ parsed = self._parse_provider_response(raw)
188
+ if getattr(self.settings, "acp_trace", False):
189
+ if parsed.tool_calls:
190
+ names = [tc.get("function", {}).get("name") for tc in parsed.tool_calls]
191
+ detail = f"tool_calls={names}"
192
+ else:
193
+ detail = f"content={(parsed.content or '')[:500]!r}"
194
+ log_agent_meta(
195
+ role=role,
196
+ model=self.model,
197
+ agent_key=self.agent_key,
198
+ mode=mode,
199
+ label="Agno ← ACP (parsed)",
200
+ detail=detail,
201
+ )
202
+ return parsed
203
+
204
+ async def ainvoke(
205
+ self,
206
+ messages: List[Message],
207
+ assistant_message: Message,
208
+ response_format: Optional[Union[Dict, Type[BaseModel]]] = None,
209
+ tools: Optional[List[Dict[str, Any]]] = None,
210
+ tool_choice: Optional[Union[str, Dict[str, Any]]] = None,
211
+ run_response: Optional[Union[RunOutput, TeamRunOutput]] = None,
212
+ compress_tool_results: bool = False,
213
+ ) -> ModelResponse:
214
+ return await asyncio.to_thread(
215
+ self.invoke,
216
+ messages,
217
+ assistant_message,
218
+ response_format,
219
+ tools,
220
+ tool_choice,
221
+ run_response,
222
+ compress_tool_results,
223
+ )
224
+
225
+ def invoke_stream(
226
+ self,
227
+ messages: List[Message],
228
+ assistant_message: Message,
229
+ response_format: Optional[Union[Dict, Type[BaseModel]]] = None,
230
+ tools: Optional[List[Dict[str, Any]]] = None,
231
+ tool_choice: Optional[Union[str, Dict[str, Any]]] = None,
232
+ run_response: Optional[Union[RunOutput, TeamRunOutput]] = None,
233
+ compress_tool_results: bool = False,
234
+ ) -> Iterator[ModelResponse]:
235
+ yield self.invoke(
236
+ messages,
237
+ assistant_message,
238
+ response_format,
239
+ tools,
240
+ tool_choice,
241
+ run_response,
242
+ compress_tool_results,
243
+ )
244
+
245
+ async def ainvoke_stream(
246
+ self,
247
+ messages: List[Message],
248
+ assistant_message: Message,
249
+ response_format: Optional[Union[Dict, Type[BaseModel]]] = None,
250
+ tools: Optional[List[Dict[str, Any]]] = None,
251
+ tool_choice: Optional[Union[str, Dict[str, Any]]] = None,
252
+ run_response: Optional[Union[RunOutput, TeamRunOutput]] = None,
253
+ compress_tool_results: bool = False,
254
+ ) -> AsyncIterator[ModelResponse]:
255
+ result = await self.ainvoke(
256
+ messages,
257
+ assistant_message,
258
+ response_format,
259
+ tools,
260
+ tool_choice,
261
+ run_response,
262
+ compress_tool_results,
263
+ )
264
+ yield result
@@ -0,0 +1,67 @@
1
+ """Harness settings and Cursor CLI spawn helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ from pathlib import Path
8
+ from typing import Literal, Protocol, runtime_checkable
9
+
10
+ from pydantic import Field
11
+ from pydantic_settings import BaseSettings, SettingsConfigDict
12
+
13
+ AcpMode = Literal["ask", "plan", "agent"]
14
+
15
+
16
+ def build_spawn_argv(base_spawn_cmd: str, model: str, mode: AcpMode) -> list[str]:
17
+ cmd = base_spawn_cmd.split()
18
+ argv = [*cmd, "--model", model]
19
+ if mode in ("ask", "plan"):
20
+ argv.extend(["--mode", mode])
21
+ elif mode == "agent":
22
+ argv.append("--force")
23
+ argv.append("acp")
24
+ return argv
25
+
26
+
27
+ class HarnessSettings(BaseSettings):
28
+ model_config = SettingsConfigDict(
29
+ env_prefix="AGNO_CURSOR_",
30
+ env_file=".env",
31
+ env_file_encoding="utf-8",
32
+ extra="ignore",
33
+ )
34
+
35
+ base_spawn_cmd: str = "cursor-agent"
36
+ acp_trace: bool = False
37
+ project_root: Path = Field(default_factory=Path.cwd)
38
+
39
+ def spawn_argv(self, model: str, mode: AcpMode) -> list[str]:
40
+ return build_spawn_argv(self.base_spawn_cmd, model, mode)
41
+
42
+
43
+ @runtime_checkable
44
+ class ACPSettings(Protocol):
45
+ acp_trace: bool
46
+ project_root: Path
47
+
48
+ def spawn_argv(self, model: str, mode: AcpMode) -> list[str]: ...
49
+
50
+
51
+ def resolve_acp_role(settings: ACPSettings, agent_key: str) -> str:
52
+ resolve = getattr(settings, "resolve", None)
53
+ if callable(resolve):
54
+ return resolve(agent_key, agent_key).role
55
+ return agent_key
56
+
57
+
58
+ def check_cursor_login() -> None:
59
+ cli = shutil.which("cursor-agent") or shutil.which("agent")
60
+ if not cli:
61
+ raise RuntimeError("cursor-agent CLI not found on PATH")
62
+ result = subprocess.run([cli, "status"], capture_output=True, text=True, check=False)
63
+ if result.returncode != 0 or "Logged in" not in (result.stdout + result.stderr):
64
+ raise RuntimeError(
65
+ "Cursor CLI is not logged in. Run: cursor-agent login\n"
66
+ + (result.stdout or result.stderr)
67
+ )
@@ -0,0 +1,83 @@
1
+ """Colored, bounded log blocks for ACP trace (role/model header + <EOF AGENT>)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import sys
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ _TRACE_STYLE = "bold bright_cyan"
12
+ _ANSI_BEGIN = "\033[96m\033[1m"
13
+ _ANSI_END = "\033[0m"
14
+
15
+
16
+ def _color_enabled() -> bool:
17
+ if os.environ.get("NO_COLOR"):
18
+ return False
19
+ force = (
20
+ os.environ.get("AGNO_CURSOR_ACP_TRACE_COLOR")
21
+ or os.environ.get("CURSOR_TEAM_ACP_TRACE_COLOR")
22
+ or ""
23
+ ).lower()
24
+ if force in ("0", "false", "no"):
25
+ return False
26
+ if force in ("1", "true", "yes"):
27
+ return True
28
+ return sys.stderr.isatty() or sys.stdout.isatty()
29
+
30
+
31
+ def _agent_header(*, role: str, model: str, agent_key: str, mode: str, label: str) -> str:
32
+ return f"── {label} ── role={role} model={model} agent_key={agent_key} mode={mode}"
33
+
34
+
35
+ def log_agent_block(
36
+ *,
37
+ role: str,
38
+ model: str,
39
+ agent_key: str,
40
+ mode: str,
41
+ label: str,
42
+ body: str,
43
+ ) -> None:
44
+ """Emit one readable trace block: agent line, body, then <EOF AGENT>."""
45
+ header = _agent_header(role=role, model=model, agent_key=agent_key, mode=mode, label=label)
46
+ eof = "<EOF AGENT>"
47
+ plain = f"{header}\n{body}\n{eof}"
48
+
49
+ if _color_enabled():
50
+ try:
51
+ from rich.console import Console
52
+
53
+ console = Console(stderr=True, highlight=False)
54
+ console.print(f"[{_TRACE_STYLE}]{header}[/]")
55
+ console.print(body)
56
+ console.print(f"[{_TRACE_STYLE}]{eof}[/]")
57
+ console.print()
58
+ return
59
+ except Exception:
60
+ pass
61
+ logger.info("%s%s%s", _ANSI_BEGIN, plain, _ANSI_END)
62
+ return
63
+
64
+ logger.info("\n%s", plain)
65
+
66
+
67
+ def log_agent_meta(
68
+ *,
69
+ role: str,
70
+ model: str,
71
+ agent_key: str,
72
+ mode: str,
73
+ label: str,
74
+ detail: str,
75
+ ) -> None:
76
+ log_agent_block(
77
+ role=role,
78
+ model=model,
79
+ agent_key=agent_key,
80
+ mode=mode,
81
+ label=label,
82
+ body=detail,
83
+ )