tsugite-acp 0.14.1__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.
- tsugite_acp-0.14.1/.gitignore +183 -0
- tsugite_acp-0.14.1/PKG-INFO +7 -0
- tsugite_acp-0.14.1/pyproject.toml +28 -0
- tsugite_acp-0.14.1/tsugite_acp/__init__.py +5 -0
- tsugite_acp-0.14.1/tsugite_acp/client.py +328 -0
- tsugite_acp-0.14.1/tsugite_acp/config.py +65 -0
- tsugite_acp-0.14.1/tsugite_acp/models.py +44 -0
- tsugite_acp-0.14.1/tsugite_acp/policy.py +74 -0
- tsugite_acp-0.14.1/tsugite_acp/provider.py +201 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# ---> Python
|
|
2
|
+
# Byte-compiled / optimized / DLL files
|
|
3
|
+
__pycache__/
|
|
4
|
+
*.py[cod]
|
|
5
|
+
*$py.class
|
|
6
|
+
|
|
7
|
+
# C extensions
|
|
8
|
+
*.so
|
|
9
|
+
|
|
10
|
+
# Distribution / packaging
|
|
11
|
+
.Python
|
|
12
|
+
build/
|
|
13
|
+
develop-eggs/
|
|
14
|
+
dist/
|
|
15
|
+
downloads/
|
|
16
|
+
eggs/
|
|
17
|
+
.eggs/
|
|
18
|
+
lib/
|
|
19
|
+
lib64/
|
|
20
|
+
parts/
|
|
21
|
+
sdist/
|
|
22
|
+
var/
|
|
23
|
+
wheels/
|
|
24
|
+
share/python-wheels/
|
|
25
|
+
*.egg-info/
|
|
26
|
+
.installed.cfg
|
|
27
|
+
*.egg
|
|
28
|
+
MANIFEST
|
|
29
|
+
|
|
30
|
+
# PyInstaller
|
|
31
|
+
# Usually these files are written by a python script from a template
|
|
32
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
33
|
+
*.manifest
|
|
34
|
+
*.spec
|
|
35
|
+
|
|
36
|
+
# Installer logs
|
|
37
|
+
pip-log.txt
|
|
38
|
+
pip-delete-this-directory.txt
|
|
39
|
+
|
|
40
|
+
# Unit test / coverage reports
|
|
41
|
+
htmlcov/
|
|
42
|
+
.tox/
|
|
43
|
+
.nox/
|
|
44
|
+
.coverage
|
|
45
|
+
.coverage.*
|
|
46
|
+
.cache
|
|
47
|
+
nosetests.xml
|
|
48
|
+
coverage.xml
|
|
49
|
+
*.cover
|
|
50
|
+
*.py,cover
|
|
51
|
+
.hypothesis/
|
|
52
|
+
.pytest_cache/
|
|
53
|
+
cover/
|
|
54
|
+
|
|
55
|
+
# Translations
|
|
56
|
+
*.mo
|
|
57
|
+
*.pot
|
|
58
|
+
|
|
59
|
+
# Django stuff:
|
|
60
|
+
*.log
|
|
61
|
+
local_settings.py
|
|
62
|
+
db.sqlite3
|
|
63
|
+
db.sqlite3-journal
|
|
64
|
+
|
|
65
|
+
# Flask stuff:
|
|
66
|
+
instance/
|
|
67
|
+
.webassets-cache
|
|
68
|
+
|
|
69
|
+
# Scrapy stuff:
|
|
70
|
+
.scrapy
|
|
71
|
+
|
|
72
|
+
# Sphinx documentation
|
|
73
|
+
docs/_build/
|
|
74
|
+
|
|
75
|
+
# PyBuilder
|
|
76
|
+
.pybuilder/
|
|
77
|
+
target/
|
|
78
|
+
|
|
79
|
+
# Jupyter Notebook
|
|
80
|
+
.ipynb_checkpoints
|
|
81
|
+
|
|
82
|
+
# IPython
|
|
83
|
+
profile_default/
|
|
84
|
+
ipython_config.py
|
|
85
|
+
|
|
86
|
+
# pyenv
|
|
87
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
88
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
89
|
+
# .python-version
|
|
90
|
+
|
|
91
|
+
# pipenv
|
|
92
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
93
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
94
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
95
|
+
# install all needed dependencies.
|
|
96
|
+
#Pipfile.lock
|
|
97
|
+
|
|
98
|
+
# poetry
|
|
99
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
100
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
101
|
+
# commonly ignored for libraries.
|
|
102
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
103
|
+
#poetry.lock
|
|
104
|
+
|
|
105
|
+
# pdm
|
|
106
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
107
|
+
#pdm.lock
|
|
108
|
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
109
|
+
# in version control.
|
|
110
|
+
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
|
111
|
+
.pdm.toml
|
|
112
|
+
.pdm-python
|
|
113
|
+
.pdm-build/
|
|
114
|
+
|
|
115
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
116
|
+
__pypackages__/
|
|
117
|
+
|
|
118
|
+
# Celery stuff
|
|
119
|
+
celerybeat-schedule
|
|
120
|
+
celerybeat.pid
|
|
121
|
+
|
|
122
|
+
# SageMath parsed files
|
|
123
|
+
*.sage.py
|
|
124
|
+
|
|
125
|
+
# Environments
|
|
126
|
+
.env
|
|
127
|
+
.venv
|
|
128
|
+
env/
|
|
129
|
+
venv/
|
|
130
|
+
ENV/
|
|
131
|
+
env.bak/
|
|
132
|
+
venv.bak/
|
|
133
|
+
|
|
134
|
+
# Spyder project settings
|
|
135
|
+
.spyderproject
|
|
136
|
+
.spyproject
|
|
137
|
+
|
|
138
|
+
# Rope project settings
|
|
139
|
+
.ropeproject
|
|
140
|
+
|
|
141
|
+
# mkdocs documentation
|
|
142
|
+
/site
|
|
143
|
+
|
|
144
|
+
# mypy
|
|
145
|
+
.mypy_cache/
|
|
146
|
+
.dmypy.json
|
|
147
|
+
dmypy.json
|
|
148
|
+
|
|
149
|
+
# Pyre type checker
|
|
150
|
+
.pyre/
|
|
151
|
+
|
|
152
|
+
# pytype static type analyzer
|
|
153
|
+
.pytype/
|
|
154
|
+
|
|
155
|
+
# Cython debug symbols
|
|
156
|
+
cython_debug/
|
|
157
|
+
|
|
158
|
+
# PyCharm
|
|
159
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
160
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
161
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
162
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
163
|
+
#.idea/
|
|
164
|
+
|
|
165
|
+
.env
|
|
166
|
+
.env
|
|
167
|
+
benchmark_results/
|
|
168
|
+
test_output/
|
|
169
|
+
.claude/settings.local.json
|
|
170
|
+
std*.txt
|
|
171
|
+
secrets/*
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# TODO: temp - I need to clean up the docs
|
|
175
|
+
docs-old/
|
|
176
|
+
examples/*
|
|
177
|
+
!examples/tsugite-example-plugin/
|
|
178
|
+
agents/
|
|
179
|
+
.claude/
|
|
180
|
+
.tsugite/
|
|
181
|
+
benchmarks/
|
|
182
|
+
docker-compose.test.yml
|
|
183
|
+
#### TODO ^^^
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tsugite-acp"
|
|
3
|
+
version = "0.14.1"
|
|
4
|
+
description = "Tsugite plugin: ACP provider routing through claude-agent-acp"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"tsugite-cli==0.14.1",
|
|
8
|
+
"agent-client-protocol>=0.9.0",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[project.entry-points."tsugite.providers"]
|
|
12
|
+
acp = "tsugite_acp:create_provider"
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["hatchling"]
|
|
16
|
+
build-backend = "hatchling.build"
|
|
17
|
+
|
|
18
|
+
[tool.hatch.build.targets.wheel]
|
|
19
|
+
packages = ["tsugite_acp"]
|
|
20
|
+
|
|
21
|
+
[tool.hatch.build.targets.sdist]
|
|
22
|
+
include = [
|
|
23
|
+
"/tsugite_acp",
|
|
24
|
+
"/pyproject.toml",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[tool.uv.sources]
|
|
28
|
+
tsugite-cli = { workspace = true }
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""ACPClientSession: handshake + prompt-turn loop bridging an ACP agent to tsugite.
|
|
2
|
+
|
|
3
|
+
The Client implementation captures session_update notifications onto an asyncio.Queue.
|
|
4
|
+
ACPClientSession.prompt() runs the agent's prompt() as a background task while draining
|
|
5
|
+
the queue, so the caller sees text/thought chunks as an async iterator and a final
|
|
6
|
+
"done" event with the stop reason and usage.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
import shutil
|
|
14
|
+
from collections import deque
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
17
|
+
from typing import Any, AsyncIterator, Literal, get_args
|
|
18
|
+
|
|
19
|
+
from acp import PROTOCOL_VERSION, connect_to_agent
|
|
20
|
+
from acp.schema import (
|
|
21
|
+
AgentCapabilities,
|
|
22
|
+
AgentMessageChunk,
|
|
23
|
+
AgentThoughtChunk,
|
|
24
|
+
AllowedOutcome,
|
|
25
|
+
ClientCapabilities,
|
|
26
|
+
FileSystemCapabilities,
|
|
27
|
+
Implementation,
|
|
28
|
+
RequestPermissionResponse,
|
|
29
|
+
StopReason,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
from tsugite.exceptions import AgentExecutionError
|
|
33
|
+
from tsugite_acp.policy import PermissionPolicy
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
_STDERR_BUFFER_LINES = 200
|
|
38
|
+
_FATAL_STOP_REASONS = frozenset(get_args(StopReason)) - {"end_turn", "cancelled"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _plugin_version() -> str:
|
|
42
|
+
try:
|
|
43
|
+
return version("tsugite-acp")
|
|
44
|
+
except PackageNotFoundError:
|
|
45
|
+
return "0.0.0"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class ACPEvent:
|
|
50
|
+
"""Yielded from ACPClientSession.prompt(). One of text/thought/done."""
|
|
51
|
+
|
|
52
|
+
kind: Literal["text", "thought", "done"]
|
|
53
|
+
text: str = ""
|
|
54
|
+
stop_reason: str | None = None
|
|
55
|
+
usage: dict | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ACPClientHandler:
|
|
59
|
+
"""Implementation of acp.Client. Bridges agent notifications onto a queue.
|
|
60
|
+
|
|
61
|
+
Capabilities advertised by ACPClientSession declare fs/terminal as unsupported,
|
|
62
|
+
so the corresponding callbacks should never fire - they raise NotImplementedError
|
|
63
|
+
if they do.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, policy: PermissionPolicy | None = None) -> None:
|
|
67
|
+
self._queue: asyncio.Queue = asyncio.Queue()
|
|
68
|
+
self._policy = policy or PermissionPolicy()
|
|
69
|
+
|
|
70
|
+
async def session_update(self, session_id: str, update, **_: Any) -> None:
|
|
71
|
+
await self._queue.put(update)
|
|
72
|
+
|
|
73
|
+
async def request_permission(self, options, session_id: str, tool_call, **_: Any) -> RequestPermissionResponse:
|
|
74
|
+
if not options:
|
|
75
|
+
raise AgentExecutionError("agent requested permission with no options")
|
|
76
|
+
|
|
77
|
+
tool_name = self._tool_name_from_call(tool_call)
|
|
78
|
+
params = self._tool_params_from_call(tool_call)
|
|
79
|
+
action = self._policy.evaluate(tool_name, params)
|
|
80
|
+
|
|
81
|
+
if action == "deny":
|
|
82
|
+
chosen = self._first_option_of_kinds(options, ("reject_once", "reject_always"))
|
|
83
|
+
if chosen is None:
|
|
84
|
+
logger.warning("policy denied %s but no reject option offered; passing first option", tool_name)
|
|
85
|
+
chosen = options[0]
|
|
86
|
+
else:
|
|
87
|
+
logger.warning("policy denied %s(%s)", tool_name, params)
|
|
88
|
+
else:
|
|
89
|
+
chosen = self._first_option_of_kinds(options, ("allow_once", "allow_always")) or options[0]
|
|
90
|
+
|
|
91
|
+
return RequestPermissionResponse(outcome=AllowedOutcome(option_id=chosen.option_id, outcome="selected"))
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def _first_option_of_kinds(options, kinds: tuple[str, ...]):
|
|
95
|
+
for opt in options:
|
|
96
|
+
if opt.kind in kinds:
|
|
97
|
+
return opt
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def _tool_name_from_call(tool_call) -> str:
|
|
102
|
+
# ToolCallUpdate doesn't carry a tool name; the title is the closest proxy
|
|
103
|
+
# used by claude-agent-acp (e.g. "Read foo", "Bash git status").
|
|
104
|
+
title = getattr(tool_call, "title", "") or ""
|
|
105
|
+
return title.split(" ", 1)[0] if title else ""
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def _tool_params_from_call(tool_call) -> dict:
|
|
109
|
+
raw = getattr(tool_call, "raw_input", None)
|
|
110
|
+
return dict(raw) if isinstance(raw, dict) else {}
|
|
111
|
+
|
|
112
|
+
async def write_text_file(self, *_a: Any, **_kw: Any): # pragma: no cover - unsupported
|
|
113
|
+
raise NotImplementedError("write_text_file capability is unsupported")
|
|
114
|
+
|
|
115
|
+
async def read_text_file(self, *_a: Any, **_kw: Any): # pragma: no cover - unsupported
|
|
116
|
+
raise NotImplementedError("read_text_file capability is unsupported")
|
|
117
|
+
|
|
118
|
+
async def create_terminal(self, *_a: Any, **_kw: Any): # pragma: no cover - unsupported
|
|
119
|
+
raise NotImplementedError("terminal capability is unsupported")
|
|
120
|
+
|
|
121
|
+
async def terminal_output(self, *_a: Any, **_kw: Any): # pragma: no cover - unsupported
|
|
122
|
+
raise NotImplementedError("terminal capability is unsupported")
|
|
123
|
+
|
|
124
|
+
async def release_terminal(self, *_a: Any, **_kw: Any): # pragma: no cover - unsupported
|
|
125
|
+
raise NotImplementedError("terminal capability is unsupported")
|
|
126
|
+
|
|
127
|
+
async def wait_for_terminal_exit(self, *_a: Any, **_kw: Any): # pragma: no cover - unsupported
|
|
128
|
+
raise NotImplementedError("terminal capability is unsupported")
|
|
129
|
+
|
|
130
|
+
async def kill_terminal(self, *_a: Any, **_kw: Any): # pragma: no cover - unsupported
|
|
131
|
+
raise NotImplementedError("terminal capability is unsupported")
|
|
132
|
+
|
|
133
|
+
async def ext_method(self, method: str, params: dict) -> dict:
|
|
134
|
+
return {}
|
|
135
|
+
|
|
136
|
+
async def ext_notification(self, method: str, params: dict) -> None:
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
def on_connect(self, conn) -> None: # noqa: ARG002
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class ACPClientSession:
|
|
144
|
+
"""Lifecycle wrapper around an ACP ClientSideConnection.
|
|
145
|
+
|
|
146
|
+
Constructor takes the connection + handler so tests can inject mocks. Production
|
|
147
|
+
code uses a factory (see :func:`spawn_acp_session`) that wraps acp.spawn_agent_process.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
def __init__(self, handler: ACPClientHandler, conn: Any) -> None:
|
|
151
|
+
self._handler = handler
|
|
152
|
+
self._conn = conn
|
|
153
|
+
self._session_id: str | None = None
|
|
154
|
+
self.agent_capabilities: AgentCapabilities | None = None
|
|
155
|
+
self._process: asyncio.subprocess.Process | None = None # set by spawn_acp_session
|
|
156
|
+
self._stderr_task: asyncio.Task | None = None
|
|
157
|
+
self._stderr_lines: deque[str] = deque(maxlen=_STDERR_BUFFER_LINES)
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def session_id(self) -> str | None:
|
|
161
|
+
return self._session_id
|
|
162
|
+
|
|
163
|
+
async def start(
|
|
164
|
+
self,
|
|
165
|
+
*,
|
|
166
|
+
cwd: str,
|
|
167
|
+
resume_session_id: str | None = None,
|
|
168
|
+
mcp_servers: list | None = None,
|
|
169
|
+
) -> str:
|
|
170
|
+
init_resp = await self._conn.initialize(
|
|
171
|
+
protocol_version=PROTOCOL_VERSION,
|
|
172
|
+
client_capabilities=ClientCapabilities(
|
|
173
|
+
fs=FileSystemCapabilities(read_text_file=False, write_text_file=False),
|
|
174
|
+
terminal=False,
|
|
175
|
+
),
|
|
176
|
+
client_info=Implementation(name="tsugite-acp", version=_plugin_version()),
|
|
177
|
+
)
|
|
178
|
+
self.agent_capabilities = init_resp.agent_capabilities
|
|
179
|
+
|
|
180
|
+
if resume_session_id:
|
|
181
|
+
await self._conn.load_session(
|
|
182
|
+
cwd=cwd,
|
|
183
|
+
session_id=resume_session_id,
|
|
184
|
+
mcp_servers=mcp_servers,
|
|
185
|
+
)
|
|
186
|
+
self._session_id = resume_session_id
|
|
187
|
+
else:
|
|
188
|
+
new_resp = await self._conn.new_session(cwd=cwd, mcp_servers=mcp_servers)
|
|
189
|
+
self._session_id = new_resp.session_id
|
|
190
|
+
return self._session_id
|
|
191
|
+
|
|
192
|
+
async def prompt(self, blocks: list) -> AsyncIterator[ACPEvent]:
|
|
193
|
+
if self._session_id is None:
|
|
194
|
+
raise RuntimeError("ACPClientSession.start() must be called before prompt()")
|
|
195
|
+
|
|
196
|
+
sentinel = object()
|
|
197
|
+
prompt_task = asyncio.create_task(self._conn.prompt(prompt=blocks, session_id=self._session_id))
|
|
198
|
+
|
|
199
|
+
async def signal_done() -> None:
|
|
200
|
+
try:
|
|
201
|
+
await prompt_task
|
|
202
|
+
except BaseException: # noqa: BLE001 - propagate via prompt_task.exception()
|
|
203
|
+
pass
|
|
204
|
+
finally:
|
|
205
|
+
await self._handler._queue.put(sentinel)
|
|
206
|
+
|
|
207
|
+
signal_task = asyncio.create_task(signal_done())
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
while True:
|
|
211
|
+
item = await self._handler._queue.get()
|
|
212
|
+
if item is sentinel:
|
|
213
|
+
break
|
|
214
|
+
event = self._convert_update(item)
|
|
215
|
+
if event is not None:
|
|
216
|
+
yield event
|
|
217
|
+
except BaseException:
|
|
218
|
+
prompt_task.cancel()
|
|
219
|
+
signal_task.cancel()
|
|
220
|
+
raise
|
|
221
|
+
finally:
|
|
222
|
+
try:
|
|
223
|
+
await signal_task
|
|
224
|
+
except (asyncio.CancelledError, Exception):
|
|
225
|
+
pass
|
|
226
|
+
|
|
227
|
+
exc = prompt_task.exception()
|
|
228
|
+
if exc is not None:
|
|
229
|
+
raise exc
|
|
230
|
+
|
|
231
|
+
resp = prompt_task.result()
|
|
232
|
+
stop = resp.stop_reason
|
|
233
|
+
if stop in _FATAL_STOP_REASONS:
|
|
234
|
+
raise AgentExecutionError(f"ACP agent stopped: {stop}")
|
|
235
|
+
|
|
236
|
+
yield ACPEvent(
|
|
237
|
+
kind="done",
|
|
238
|
+
stop_reason=stop,
|
|
239
|
+
usage=resp.usage.model_dump() if getattr(resp, "usage", None) else None,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
@staticmethod
|
|
243
|
+
def _convert_update(update) -> ACPEvent | None:
|
|
244
|
+
if isinstance(update, AgentMessageChunk):
|
|
245
|
+
return ACPEvent(kind="text", text=update.content.text)
|
|
246
|
+
if isinstance(update, AgentThoughtChunk):
|
|
247
|
+
return ACPEvent(kind="thought", text=update.content.text)
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
async def cancel(self) -> None:
|
|
251
|
+
if self._session_id is not None:
|
|
252
|
+
await self._conn.cancel(session_id=self._session_id)
|
|
253
|
+
|
|
254
|
+
async def close(self) -> None:
|
|
255
|
+
session_caps = getattr(self.agent_capabilities, "session_capabilities", None)
|
|
256
|
+
if self._session_id is not None and getattr(session_caps, "close", None) is not None:
|
|
257
|
+
try:
|
|
258
|
+
await self._conn.close_session(session_id=self._session_id)
|
|
259
|
+
except Exception as e: # pragma: no cover - best effort shutdown
|
|
260
|
+
logger.debug("close_session failed (continuing): %s", e)
|
|
261
|
+
try:
|
|
262
|
+
await self._conn.close()
|
|
263
|
+
except Exception as e: # pragma: no cover
|
|
264
|
+
logger.debug("conn.close failed (continuing): %s", e)
|
|
265
|
+
|
|
266
|
+
if self._stderr_task is not None:
|
|
267
|
+
self._stderr_task.cancel()
|
|
268
|
+
self._stderr_task = None
|
|
269
|
+
|
|
270
|
+
if self._process is not None:
|
|
271
|
+
try:
|
|
272
|
+
self._process.terminate()
|
|
273
|
+
await asyncio.wait_for(self._process.wait(), timeout=2.0)
|
|
274
|
+
except (ProcessLookupError, asyncio.TimeoutError):
|
|
275
|
+
try:
|
|
276
|
+
self._process.kill()
|
|
277
|
+
except ProcessLookupError:
|
|
278
|
+
pass
|
|
279
|
+
self._process = None
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
async def spawn_acp_session(
|
|
283
|
+
*,
|
|
284
|
+
command: str,
|
|
285
|
+
args: list[str] | None = None,
|
|
286
|
+
env: dict[str, str] | None = None,
|
|
287
|
+
cwd: str | None = None,
|
|
288
|
+
policy: PermissionPolicy | None = None,
|
|
289
|
+
) -> ACPClientSession:
|
|
290
|
+
"""Spawn an ACP agent subprocess and wire up an ACPClientSession.
|
|
291
|
+
|
|
292
|
+
Caller is responsible for awaiting :meth:`ACPClientSession.start` to perform the
|
|
293
|
+
initialize/new_session handshake, and :meth:`ACPClientSession.close` to terminate.
|
|
294
|
+
"""
|
|
295
|
+
if shutil.which(command) is None and "/" not in command:
|
|
296
|
+
raise RuntimeError(
|
|
297
|
+
f"ACP agent command {command!r} not found on PATH. "
|
|
298
|
+
"Install Node.js + npm and verify `npx --version`, or set TSUGITE_ACP_COMMAND."
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
process = await asyncio.create_subprocess_exec(
|
|
302
|
+
command,
|
|
303
|
+
*(args or []),
|
|
304
|
+
stdin=asyncio.subprocess.PIPE,
|
|
305
|
+
stdout=asyncio.subprocess.PIPE,
|
|
306
|
+
stderr=asyncio.subprocess.PIPE,
|
|
307
|
+
env=env,
|
|
308
|
+
cwd=cwd,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
handler = ACPClientHandler(policy=policy)
|
|
312
|
+
conn = connect_to_agent(handler, process.stdin, process.stdout)
|
|
313
|
+
session = ACPClientSession(handler=handler, conn=conn)
|
|
314
|
+
session._process = process
|
|
315
|
+
session._stderr_task = asyncio.create_task(_drain_stream(process.stderr, session._stderr_lines))
|
|
316
|
+
return session
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
async def _drain_stream(stream, sink: deque[str]) -> None:
|
|
320
|
+
"""Read lines from a stream into a bounded sink to keep the pipe from blocking."""
|
|
321
|
+
try:
|
|
322
|
+
while True:
|
|
323
|
+
line = await stream.readline()
|
|
324
|
+
if not line:
|
|
325
|
+
break
|
|
326
|
+
sink.append(line.decode(errors="replace").rstrip())
|
|
327
|
+
except Exception:
|
|
328
|
+
pass
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Resolve the ACP agent command, env, and cwd."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
DEFAULT_COMMAND: list[str] = ["npx", "-y", "@agentclientprotocol/claude-agent-acp"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class ACPCommandConfig:
|
|
15
|
+
"""Resolved command, env, and cwd for spawning an ACP agent subprocess."""
|
|
16
|
+
|
|
17
|
+
command: str
|
|
18
|
+
args: list[str]
|
|
19
|
+
env: dict[str, str]
|
|
20
|
+
cwd: str | None
|
|
21
|
+
|
|
22
|
+
def argv(self) -> list[str]:
|
|
23
|
+
return [self.command, *self.args]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def resolve_command(env_override: str | None = None, config: dict | None = None) -> ACPCommandConfig:
|
|
27
|
+
"""Resolve the ACP command using this precedence:
|
|
28
|
+
|
|
29
|
+
1. `env_override` (or `TSUGITE_ACP_COMMAND`) parsed via shlex
|
|
30
|
+
2. `config["command"]` from a workspace/user config dict
|
|
31
|
+
3. `DEFAULT_COMMAND` (npx claude-agent-acp)
|
|
32
|
+
"""
|
|
33
|
+
raw = env_override if env_override is not None else os.environ.get("TSUGITE_ACP_COMMAND")
|
|
34
|
+
if raw:
|
|
35
|
+
parts = shlex.split(raw)
|
|
36
|
+
elif config and config.get("command"):
|
|
37
|
+
c = config["command"]
|
|
38
|
+
parts = shlex.split(c) if isinstance(c, str) else list(c)
|
|
39
|
+
else:
|
|
40
|
+
parts = list(DEFAULT_COMMAND)
|
|
41
|
+
|
|
42
|
+
if not parts:
|
|
43
|
+
raise ValueError("ACP command resolved to empty argv")
|
|
44
|
+
|
|
45
|
+
cwd = (config or {}).get("cwd")
|
|
46
|
+
env = _build_env((config or {}).get("env"))
|
|
47
|
+
return ACPCommandConfig(command=parts[0], args=parts[1:], env=env, cwd=cwd)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _build_env(extra: dict | None) -> dict[str, str]:
|
|
51
|
+
"""Inherit current env, then layer in any explicit entries from config."""
|
|
52
|
+
env = dict(os.environ)
|
|
53
|
+
if extra:
|
|
54
|
+
env.update({str(k): str(v) for k, v in extra.items()})
|
|
55
|
+
return env
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def workspace_cwd() -> str:
|
|
59
|
+
"""Best-effort cwd: tsugite workspace dir if set, else process cwd."""
|
|
60
|
+
try:
|
|
61
|
+
from tsugite.cli.helpers import get_workspace_dir
|
|
62
|
+
except ImportError:
|
|
63
|
+
return str(Path.cwd())
|
|
64
|
+
ws = get_workspace_dir()
|
|
65
|
+
return str(ws) if ws is not None else str(Path.cwd())
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Model catalog and alias resolution for the ACP provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from tsugite.providers.base import ModelInfo
|
|
6
|
+
from tsugite.providers.model_registry import register_models
|
|
7
|
+
|
|
8
|
+
_ACP_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _model(max_input_tokens: int) -> ModelInfo:
|
|
12
|
+
return ModelInfo(
|
|
13
|
+
max_input_tokens=max_input_tokens,
|
|
14
|
+
supports_vision=True,
|
|
15
|
+
supported_effort_levels=_ACP_EFFORT_LEVELS,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_ACP_MODELS: dict[str, ModelInfo] = {
|
|
20
|
+
"acp/claude-opus-4-7": _model(1_000_000),
|
|
21
|
+
"acp/claude-opus-4-6": _model(1_000_000),
|
|
22
|
+
"acp/claude-sonnet-4-6": _model(1_000_000),
|
|
23
|
+
"acp/claude-haiku-4-5-20251001": _model(200_000),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
_ALIASES: dict[str, str] = {
|
|
27
|
+
"opus": "claude-opus-4-7",
|
|
28
|
+
"opus-4-7": "claude-opus-4-7",
|
|
29
|
+
"opus-4-6": "claude-opus-4-6",
|
|
30
|
+
"sonnet": "claude-sonnet-4-6",
|
|
31
|
+
"haiku": "claude-haiku-4-5-20251001",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def resolve_model_alias(model: str) -> str:
|
|
36
|
+
"""Map a short alias (`opus`, `sonnet`) to a full model id; pass full ids through."""
|
|
37
|
+
if not model:
|
|
38
|
+
raise ValueError("model must be a non-empty string")
|
|
39
|
+
return _ALIASES.get(model, model)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def register_acp_models() -> None:
|
|
43
|
+
"""Register the ACP catalog into tsugite's shared model registry."""
|
|
44
|
+
register_models(_ACP_MODELS)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Permission policy for ACP request_permission callbacks.
|
|
2
|
+
|
|
3
|
+
Rules are simple strings: ``ToolName`` for an exact match, ``ToolName(glob)`` to
|
|
4
|
+
match the tool's primary string argument against a shell glob (fnmatch). For
|
|
5
|
+
Bash, the primary argument is the ``command`` key; for other tools it falls back
|
|
6
|
+
to the full params repr.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import fnmatch
|
|
12
|
+
import logging
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Literal
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
Action = Literal["allow", "deny"]
|
|
20
|
+
|
|
21
|
+
_RULE_RE = re.compile(r"^(?P<tool>[A-Za-z_][A-Za-z0-9_]*)(?:\((?P<glob>.*)\))?$")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class _Rule:
|
|
26
|
+
tool: str
|
|
27
|
+
glob: str | None # None = match the tool name alone
|
|
28
|
+
action: Action
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _parse_rule(rule: str, action: Action) -> _Rule:
|
|
32
|
+
m = _RULE_RE.match(rule.strip())
|
|
33
|
+
if not m:
|
|
34
|
+
raise ValueError(f"invalid permission rule: {rule!r}")
|
|
35
|
+
return _Rule(tool=m.group("tool"), glob=m.group("glob"), action=action)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _stringify_params(params: dict) -> str:
|
|
39
|
+
if "command" in params:
|
|
40
|
+
return str(params["command"])
|
|
41
|
+
return str(params)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class PermissionPolicy:
|
|
46
|
+
default: Action = "allow"
|
|
47
|
+
allow: list[str] = field(default_factory=list)
|
|
48
|
+
deny: list[str] = field(default_factory=list)
|
|
49
|
+
_rules: list[_Rule] = field(default_factory=list, init=False, repr=False)
|
|
50
|
+
|
|
51
|
+
def __post_init__(self) -> None:
|
|
52
|
+
# Deny rules evaluated first so they can override an allow.
|
|
53
|
+
self._rules = [_parse_rule(r, "deny") for r in self.deny] + [_parse_rule(r, "allow") for r in self.allow]
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_config(cls, config: dict | None) -> "PermissionPolicy":
|
|
57
|
+
if not config:
|
|
58
|
+
return cls()
|
|
59
|
+
return cls(
|
|
60
|
+
default=config.get("default", "allow"),
|
|
61
|
+
allow=list(config.get("allow", [])),
|
|
62
|
+
deny=list(config.get("deny", [])),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def evaluate(self, tool: str, params: dict) -> Action:
|
|
66
|
+
arg_str = _stringify_params(params)
|
|
67
|
+
for rule in self._rules:
|
|
68
|
+
if rule.tool != tool:
|
|
69
|
+
continue
|
|
70
|
+
if rule.glob is None:
|
|
71
|
+
return rule.action
|
|
72
|
+
if fnmatch.fnmatchcase(arg_str, rule.glob):
|
|
73
|
+
return rule.action
|
|
74
|
+
return self.default
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""ACPProvider: routes tsugite completions through an ACP-compatible agent subprocess."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, AsyncIterator, Callable
|
|
6
|
+
|
|
7
|
+
from acp.schema import TextContentBlock
|
|
8
|
+
|
|
9
|
+
from tsugite.providers.base import CompletionResponse, ModelInfo, StreamChunk, Usage, default_count_tokens
|
|
10
|
+
from tsugite.providers.model_registry import get_model_info as _registry_get
|
|
11
|
+
from tsugite_acp.client import ACPClientSession
|
|
12
|
+
from tsugite_acp.config import workspace_cwd
|
|
13
|
+
from tsugite_acp.models import _ALIASES, register_acp_models, resolve_model_alias
|
|
14
|
+
|
|
15
|
+
register_acp_models()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ACPProvider:
|
|
19
|
+
"""Provider backed by an ACP agent process (default: claude-agent-acp).
|
|
20
|
+
|
|
21
|
+
Stateful: the agent subprocess persists across acompletion() calls within a
|
|
22
|
+
session. First acompletion spawns and handshakes; subsequent calls reuse the
|
|
23
|
+
session. Call stop() to release the subprocess.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
cacheable = False
|
|
27
|
+
|
|
28
|
+
def __init__(self, name: str = "acp", session_factory: Callable[[], ACPClientSession] | None = None):
|
|
29
|
+
self.name = name
|
|
30
|
+
self._session: ACPClientSession | None = None
|
|
31
|
+
self._session_factory = session_factory
|
|
32
|
+
|
|
33
|
+
self._attachments: list = []
|
|
34
|
+
self._skills: list = []
|
|
35
|
+
self._previous_messages: list[dict] = []
|
|
36
|
+
self._resume_session: str | None = None
|
|
37
|
+
self._resume_after_compaction: bool = False
|
|
38
|
+
|
|
39
|
+
self._session_id: str | None = None
|
|
40
|
+
self._cache_creation_tokens: int = 0
|
|
41
|
+
self._cache_read_tokens: int = 0
|
|
42
|
+
self._context_window: int | None = None
|
|
43
|
+
|
|
44
|
+
def set_context(self, **kwargs: Any) -> None:
|
|
45
|
+
self._attachments = kwargs.get("attachments", [])
|
|
46
|
+
self._skills = kwargs.get("skills", [])
|
|
47
|
+
self._previous_messages = kwargs.get("previous_messages", [])
|
|
48
|
+
self._resume_session = kwargs.get("resume_session")
|
|
49
|
+
self._resume_after_compaction = kwargs.get("resume_after_compaction", False)
|
|
50
|
+
|
|
51
|
+
def get_state(self) -> dict | None:
|
|
52
|
+
return {
|
|
53
|
+
"session_id": self._session_id,
|
|
54
|
+
"cache_creation_tokens": self._cache_creation_tokens,
|
|
55
|
+
"cache_read_tokens": self._cache_read_tokens,
|
|
56
|
+
"context_window": self._context_window,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async def stop(self) -> None:
|
|
60
|
+
if self._session is not None:
|
|
61
|
+
await self._session.close()
|
|
62
|
+
self._session = None
|
|
63
|
+
|
|
64
|
+
async def acompletion(
|
|
65
|
+
self,
|
|
66
|
+
messages: list[dict],
|
|
67
|
+
model: str,
|
|
68
|
+
stream: bool = False,
|
|
69
|
+
**_kwargs: Any,
|
|
70
|
+
) -> CompletionResponse | AsyncIterator[StreamChunk]:
|
|
71
|
+
resolve_model_alias(model) # validate non-empty
|
|
72
|
+
|
|
73
|
+
if self._session is None:
|
|
74
|
+
await self._spawn_session()
|
|
75
|
+
blocks = self._build_first_prompt(messages)
|
|
76
|
+
else:
|
|
77
|
+
blocks = self._latest_user_blocks(messages)
|
|
78
|
+
|
|
79
|
+
if stream:
|
|
80
|
+
return self._stream_turn(blocks)
|
|
81
|
+
return await self._collect_turn(blocks)
|
|
82
|
+
|
|
83
|
+
async def _spawn_session(self) -> None:
|
|
84
|
+
if self._session_factory is not None:
|
|
85
|
+
self._session = self._session_factory()
|
|
86
|
+
else:
|
|
87
|
+
from tsugite_acp.client import spawn_acp_session
|
|
88
|
+
from tsugite_acp.config import resolve_command
|
|
89
|
+
|
|
90
|
+
cmd = resolve_command()
|
|
91
|
+
self._session = await spawn_acp_session(
|
|
92
|
+
command=cmd.command,
|
|
93
|
+
args=cmd.args,
|
|
94
|
+
env=cmd.env,
|
|
95
|
+
cwd=cmd.cwd or workspace_cwd(),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
await self._session.start(cwd=workspace_cwd(), resume_session_id=self._resume_session)
|
|
99
|
+
|
|
100
|
+
def _build_first_prompt(self, messages: list[dict]) -> list[TextContentBlock]:
|
|
101
|
+
from tsugite.attachments.base import AttachmentContentType, format_attachment_open_tag
|
|
102
|
+
|
|
103
|
+
parts: list[str] = []
|
|
104
|
+
|
|
105
|
+
include_context = not self._resume_session or self._resume_after_compaction
|
|
106
|
+
if include_context and (self._attachments or self._skills):
|
|
107
|
+
ctx: list[str] = []
|
|
108
|
+
for att in self._attachments:
|
|
109
|
+
if getattr(att, "content_type", None) != AttachmentContentType.TEXT:
|
|
110
|
+
continue
|
|
111
|
+
ctx.append(format_attachment_open_tag(att))
|
|
112
|
+
ctx.append(att.content)
|
|
113
|
+
ctx.append("</attachment>")
|
|
114
|
+
for skill in self._skills:
|
|
115
|
+
content = getattr(skill, "content", "")
|
|
116
|
+
if len(content) > 4000:
|
|
117
|
+
content = content[:4000] + "\n... (truncated)"
|
|
118
|
+
ctx.append(f'<skill_content name="{skill.name}">\n{content}\n</skill_content>')
|
|
119
|
+
if ctx:
|
|
120
|
+
parts.append("<context>\n" + "\n".join(ctx) + "\n</context>")
|
|
121
|
+
|
|
122
|
+
if self._previous_messages and not self._resume_session:
|
|
123
|
+
history = "\n\n".join(
|
|
124
|
+
f"{m.get('role', 'unknown').capitalize()}: {m.get('content', '')}" for m in self._previous_messages
|
|
125
|
+
)
|
|
126
|
+
parts.append(f"<conversation_history>\n{history}\n</conversation_history>")
|
|
127
|
+
|
|
128
|
+
parts.append(_extract_latest_user_text(messages))
|
|
129
|
+
text = "\n".join(p for p in parts if p)
|
|
130
|
+
return [TextContentBlock(type="text", text=text)]
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def _latest_user_blocks(messages: list[dict]) -> list[TextContentBlock]:
|
|
134
|
+
return [TextContentBlock(type="text", text=_extract_latest_user_text(messages))]
|
|
135
|
+
|
|
136
|
+
async def _collect_turn(self, blocks: list[TextContentBlock]) -> CompletionResponse:
|
|
137
|
+
accumulated = ""
|
|
138
|
+
usage = Usage()
|
|
139
|
+
async for ev in self._session.prompt(blocks):
|
|
140
|
+
if ev.kind == "text":
|
|
141
|
+
accumulated += ev.text
|
|
142
|
+
elif ev.kind == "done":
|
|
143
|
+
usage = self._extract_usage(ev.usage)
|
|
144
|
+
self._session_id = self._session.session_id
|
|
145
|
+
return CompletionResponse(content=accumulated, usage=usage, cost=0.0)
|
|
146
|
+
|
|
147
|
+
async def _stream_turn(self, blocks: list[TextContentBlock]) -> AsyncIterator[StreamChunk]:
|
|
148
|
+
usage = Usage()
|
|
149
|
+
async for ev in self._session.prompt(blocks):
|
|
150
|
+
if ev.kind == "text":
|
|
151
|
+
yield StreamChunk(content=ev.text)
|
|
152
|
+
elif ev.kind == "thought":
|
|
153
|
+
yield StreamChunk(reasoning_content=ev.text)
|
|
154
|
+
elif ev.kind == "done":
|
|
155
|
+
usage = self._extract_usage(ev.usage)
|
|
156
|
+
self._session_id = self._session.session_id
|
|
157
|
+
yield StreamChunk(content="", done=True, usage=usage, cost=0.0)
|
|
158
|
+
|
|
159
|
+
def _extract_usage(self, raw_usage: dict | None) -> Usage:
|
|
160
|
+
if not raw_usage:
|
|
161
|
+
return Usage()
|
|
162
|
+
prompt_tokens = int(raw_usage.get("input_tokens") or 0)
|
|
163
|
+
completion_tokens = int(raw_usage.get("output_tokens") or 0)
|
|
164
|
+
cache_creation = int(raw_usage.get("cache_creation_input_tokens") or 0)
|
|
165
|
+
cache_read = int(raw_usage.get("cache_read_input_tokens") or 0)
|
|
166
|
+
self._cache_creation_tokens += cache_creation
|
|
167
|
+
self._cache_read_tokens += cache_read
|
|
168
|
+
return Usage(
|
|
169
|
+
prompt_tokens=prompt_tokens,
|
|
170
|
+
completion_tokens=completion_tokens,
|
|
171
|
+
total_tokens=prompt_tokens + completion_tokens + cache_creation + cache_read,
|
|
172
|
+
cache_creation_input_tokens=cache_creation,
|
|
173
|
+
cache_read_input_tokens=cache_read,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def count_tokens(self, text: str, model: str) -> int:
|
|
177
|
+
return default_count_tokens(text, model)
|
|
178
|
+
|
|
179
|
+
def get_model_info(self, model: str) -> ModelInfo | None:
|
|
180
|
+
return _registry_get(self.name, resolve_model_alias(model))
|
|
181
|
+
|
|
182
|
+
async def list_models(self) -> list[str]:
|
|
183
|
+
return list(_ALIASES.keys())
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _extract_latest_user_text(messages: list[dict]) -> str:
|
|
187
|
+
"""Return the text of the most recent user message, flattening list-of-blocks content."""
|
|
188
|
+
for msg in reversed(messages):
|
|
189
|
+
if msg.get("role") != "user":
|
|
190
|
+
continue
|
|
191
|
+
content = msg["content"]
|
|
192
|
+
if isinstance(content, list):
|
|
193
|
+
return "\n".join(
|
|
194
|
+
b if isinstance(b, str) else b.get("text", "") for b in content if isinstance(b, (str, dict))
|
|
195
|
+
)
|
|
196
|
+
return content
|
|
197
|
+
return ""
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def create_provider(name: str = "acp", **_kwargs: Any) -> ACPProvider:
|
|
201
|
+
return ACPProvider(name=name)
|