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.
- agno_cursor_agent-0.1.0/.gitignore +17 -0
- agno_cursor_agent-0.1.0/LICENSE +21 -0
- agno_cursor_agent-0.1.0/PKG-INFO +91 -0
- agno_cursor_agent-0.1.0/README.md +57 -0
- agno_cursor_agent-0.1.0/pyproject.toml +70 -0
- agno_cursor_agent-0.1.0/src/agno_cursor/__init__.py +27 -0
- agno_cursor_agent-0.1.0/src/agno_cursor/acp/__init__.py +4 -0
- agno_cursor_agent-0.1.0/src/agno_cursor/acp/registry.py +53 -0
- agno_cursor_agent-0.1.0/src/agno_cursor/acp/session.py +251 -0
- agno_cursor_agent-0.1.0/src/agno_cursor/model/__init__.py +3 -0
- agno_cursor_agent-0.1.0/src/agno_cursor/model/cursor_acp.py +264 -0
- agno_cursor_agent-0.1.0/src/agno_cursor/settings.py +67 -0
- agno_cursor_agent-0.1.0/src/agno_cursor/trace.py +83 -0
|
@@ -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
|
+

|
|
38
|
+

|
|
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
|
+

|
|
4
|
+

|
|
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,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,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
|
+
)
|