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.
@@ -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,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: tsugite-acp
3
+ Version: 0.14.1
4
+ Summary: Tsugite plugin: ACP provider routing through claude-agent-acp
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: agent-client-protocol>=0.9.0
7
+ Requires-Dist: tsugite-cli==0.14.1
@@ -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,5 @@
1
+ """Tsugite plugin: ACP provider routing through claude-agent-acp."""
2
+
3
+ from tsugite_acp.provider import ACPProvider, create_provider
4
+
5
+ __all__ = ["ACPProvider", "create_provider"]
@@ -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)