langgraph-acp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,134 @@
1
+ """ACP-compatible agents as first-class LangGraph nodes.
2
+
3
+ The boundary this package exists to hold:
4
+
5
+ LangGraph owns orchestration and workflow durability.
6
+ ACP owns the agent conversation and agent-side session state.
7
+ langgraph-acp owns the binding between the two.
8
+
9
+ What is here is the vocabulary, the connection beneath it, and the first node
10
+ that joins the two halves: `ACPNode` resolves an agent, opens and initializes a
11
+ connection, starts a session, runs a prompt, and returns an `ACPResult`.
12
+
13
+ ACPAgentRegistry -> ACPAgentProvider -> ACPClient -> ACPSession
14
+ "codex" how to reach one live one conversation
15
+ an agent connection and its turns
16
+
17
+ ACPSessionStore (thread_id, session_key) -> "sess_abc123"
18
+ the binding, and only ever the binding
19
+
20
+ Naming an agent is confined to `langgraph_acp.providers`. Everything else --
21
+ every core type, the registry, the client, the session -- is written without
22
+ knowing whether it is talking to Codex or to something released next year, and
23
+ `"codex"` is a string a registry resolves rather than an import anyone makes.
24
+
25
+ Serialization follows one rule, so "does this have a `to_dict`?" has an answer
26
+ that does not need looking up. Types that leave the process serialize:
27
+ `ACPEvent` into a stream, `ACPResult` into LangGraph state. Types that only
28
+ configure a node -- `ACPSessionSpec`, `ACPWorkspace`, `ACPConfig`,
29
+ `ACPRequirements` -- do not: they are written in Python beside the graph, and a
30
+ graph definition is code rather than data.
31
+
32
+ Every field is annotated with what the constructor *accepts*, not with what it
33
+ stores: `__post_init__` normalizes, so a `Path` becomes a `str`, a list becomes
34
+ a tuple, and `"reuse"` becomes `ACPSessionStrategy.REUSE`. The stored value is
35
+ always an instance of the declared type but usually a narrower one. A stdlib
36
+ dataclass has no way to declare the two separately, and since `py.typed` ships
37
+ here, the spelling a caller writes is the one the annotation has to admit.
38
+
39
+ Containers handed in are copied, and so are containers handed back out by
40
+ `to_dict`. The copy is deep, because ACP payloads are nested and a shallow one
41
+ would leave the interesting part shared.
42
+ """
43
+
44
+ from langgraph_acp._json import JSONObject, JSONValue
45
+ from langgraph_acp.agent import (
46
+ ACPAgentProvider,
47
+ ACPAgentRegistry,
48
+ StdioACPProvider,
49
+ default_registry,
50
+ )
51
+ from langgraph_acp.client import PROTOCOL_VERSION, ACPCapabilities, ACPClient
52
+ from langgraph_acp.config import ACPConfig, ACPRequirements, UnsupportedOption
53
+ from langgraph_acp.continuation import ACPContinuation, resume_continuation
54
+ from langgraph_acp.elicitation import (
55
+ ACPElicitationHandler,
56
+ ACPElicitationRequest,
57
+ ACPElicitationResponse,
58
+ )
59
+ from langgraph_acp.errors import (
60
+ ACPAgentCapabilityError,
61
+ ACPAgentNotFoundError,
62
+ ACPConnectionError,
63
+ ACPError,
64
+ ACPSessionError,
65
+ )
66
+ from langgraph_acp.events import EVENT_NAMESPACE, ACPEvent, ACPEventType
67
+ from langgraph_acp.node import ACPNode
68
+ from langgraph_acp.permissions import (
69
+ CANCELLED,
70
+ ACPPermissionHandler,
71
+ ACPPermissionOption,
72
+ ACPPermissionOutcome,
73
+ ACPPermissionRequest,
74
+ deny_permission,
75
+ )
76
+ from langgraph_acp.providers.claude import CLAUDE_ACP_COMMAND, ClaudeACPProvider
77
+ from langgraph_acp.providers.codex import CODEX_ACP_COMMAND, CodexACPProvider
78
+ from langgraph_acp.result import ACPResult, ACPUsage
79
+ from langgraph_acp.session import (
80
+ ACPPrompt,
81
+ ACPSession,
82
+ ACPSessionSpec,
83
+ ACPSessionStrategy,
84
+ )
85
+ from langgraph_acp.store import ACPSessionStore, InMemoryACPSessionStore
86
+ from langgraph_acp.workspace import ACPWorkspace
87
+
88
+ __all__ = [
89
+ "ACPAgentCapabilityError",
90
+ "ACPAgentNotFoundError",
91
+ "ACPAgentProvider",
92
+ "ACPAgentRegistry",
93
+ "ACPCapabilities",
94
+ "ACPClient",
95
+ "ACPConfig",
96
+ "ACPConnectionError",
97
+ "ACPContinuation",
98
+ "ACPElicitationHandler",
99
+ "ACPElicitationRequest",
100
+ "ACPElicitationResponse",
101
+ "ACPError",
102
+ "ACPEvent",
103
+ "ACPEventType",
104
+ "ACPNode",
105
+ "ACPPermissionHandler",
106
+ "ACPPermissionOption",
107
+ "ACPPermissionOutcome",
108
+ "ACPPermissionRequest",
109
+ "ACPPrompt",
110
+ "ACPRequirements",
111
+ "ACPResult",
112
+ "ACPSession",
113
+ "ACPSessionError",
114
+ "ACPSessionSpec",
115
+ "ACPSessionStore",
116
+ "ACPSessionStrategy",
117
+ "ACPUsage",
118
+ "ACPWorkspace",
119
+ "CANCELLED",
120
+ "CLAUDE_ACP_COMMAND",
121
+ "CODEX_ACP_COMMAND",
122
+ "ClaudeACPProvider",
123
+ "CodexACPProvider",
124
+ "EVENT_NAMESPACE",
125
+ "InMemoryACPSessionStore",
126
+ "JSONObject",
127
+ "JSONValue",
128
+ "PROTOCOL_VERSION",
129
+ "StdioACPProvider",
130
+ "UnsupportedOption",
131
+ "default_registry",
132
+ "deny_permission",
133
+ "resume_continuation",
134
+ ]
langgraph_acp/_json.py ADDED
@@ -0,0 +1,136 @@
1
+ """JSON-shaped values: the copies that isolate them, and the reads that check them.
2
+
3
+ ACP speaks JSON-RPC, so a few fields here carry whatever the agent sent: event
4
+ payloads, result content, config values. Typing those as `JSONValue` states that
5
+ honestly rather than inventing a richer type before the tickets that normalize
6
+ them exist.
7
+
8
+ Mappings and sequences are copied on the way in and on the way out. These
9
+ objects end up in LangGraph state and in streamed events, where a container
10
+ still shared with the caller is a mutation arriving from somewhere else
11
+ entirely. The copy is *deep*, because the shape this data actually has is
12
+ nested -- an ACP `session/update` is `{"update": {...}}`, and a shallow copy
13
+ would leave the interesting part shared while the docstring claimed otherwise.
14
+ The cost is proportional to the number of containers, not to the text inside
15
+ them: strings are immutable and are shared rather than duplicated.
16
+
17
+ The copies produce plain `dict` and `tuple` rather than read-only views because
18
+ LangGraph checkpointing pickles what it stores, and `MappingProxyType` cannot be
19
+ pickled.
20
+
21
+ A lone `str` is refused wherever a sequence is expected. It is iterable, so
22
+ without the guard `additional_directories="/repos/docs"` becomes eleven
23
+ single-character roots and the mistake surfaces as a bad ACP request much later.
24
+
25
+ The `as_*` readers exist for `from_dict`, where the input is whatever a store or
26
+ a checkpoint handed back. They check rather than coerce: a token count that
27
+ returns as `"1200"` should fail at the boundary it crossed, not as an arithmetic
28
+ error in whatever later sums it.
29
+ """
30
+
31
+ from collections.abc import Iterable, Mapping
32
+ from copy import deepcopy
33
+ from typing import TypeAlias, TypeVar
34
+
35
+ JSONValue: TypeAlias = (
36
+ str
37
+ | int
38
+ | float
39
+ | bool
40
+ | None
41
+ | Iterable["JSONValue"]
42
+ | Mapping[str, "JSONValue"]
43
+ )
44
+
45
+ #: What a `to_dict` returns: a mapping the caller may keep and mutate freely.
46
+ JSONObject: TypeAlias = dict[str, JSONValue]
47
+
48
+ T = TypeVar("T")
49
+
50
+
51
+ def copied_mapping(values: Mapping[str, JSONValue] | None) -> JSONObject:
52
+ """A private `dict`, nested containers included, holding what `values` held."""
53
+ return deepcopy(dict(values or {}))
54
+
55
+
56
+ def checked_sequence(values: Iterable[T] | None, *, field: str) -> tuple[T, ...]:
57
+ """The values as a tuple, refusing the string that is quietly a sequence."""
58
+ if isinstance(values, (str, bytes)):
59
+ raise TypeError(
60
+ f"{field} takes a sequence of values, not the single "
61
+ f"{type(values).__name__} {values!r}: iterating one yields a "
62
+ "separate entry per character, which is never what was meant"
63
+ )
64
+ return tuple(values or ())
65
+
66
+
67
+ def copied_sequence(
68
+ values: Iterable[JSONValue] | None, *, field: str
69
+ ) -> tuple[JSONValue, ...]:
70
+ """A private tuple, nested containers included, holding what `values` held."""
71
+ return tuple(deepcopy(value) for value in checked_sequence(values, field=field))
72
+
73
+
74
+ def _wrong(field: str, expected: str, value: object) -> TypeError:
75
+ return TypeError(
76
+ f"{field} must be {expected}, not the {type(value).__name__} {value!r}"
77
+ )
78
+
79
+
80
+ def as_str(value: object, *, field: str) -> str:
81
+ """`value` as the string it should already be."""
82
+ if isinstance(value, str):
83
+ return value
84
+ raise _wrong(field, "a string", value)
85
+
86
+
87
+ def as_optional_str(value: object, *, field: str) -> str | None:
88
+ return None if value is None else as_str(value, field=field)
89
+
90
+
91
+ def as_optional_int(value: object, *, field: str) -> int | None:
92
+ # `bool` is an `int` in Python, and is never a token count.
93
+ if value is None or (isinstance(value, int) and not isinstance(value, bool)):
94
+ return value
95
+ raise _wrong(field, "a whole number", value)
96
+
97
+
98
+ def as_optional_float(value: object, *, field: str) -> float | None:
99
+ if value is None:
100
+ return None
101
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
102
+ return float(value)
103
+ raise _wrong(field, "a number", value)
104
+
105
+
106
+ def as_mapping(value: object, *, field: str) -> JSONObject:
107
+ """`value` as a private mapping. A missing key reads as an empty one."""
108
+ if value is None:
109
+ return {}
110
+ if isinstance(value, Mapping):
111
+ return copied_mapping(value)
112
+ raise _wrong(field, "a mapping", value)
113
+
114
+
115
+ def as_sequence(value: object, *, field: str) -> tuple[JSONValue, ...]:
116
+ """`value` as a private tuple. A missing key reads as an empty one."""
117
+ if value is None:
118
+ return ()
119
+ if not isinstance(value, Iterable):
120
+ raise _wrong(field, "a sequence of values", value)
121
+ return copied_sequence(value, field=field)
122
+
123
+
124
+ __all__ = [
125
+ "JSONObject",
126
+ "JSONValue",
127
+ "as_mapping",
128
+ "as_optional_float",
129
+ "as_optional_int",
130
+ "as_optional_str",
131
+ "as_sequence",
132
+ "as_str",
133
+ "checked_sequence",
134
+ "copied_mapping",
135
+ "copied_sequence",
136
+ ]
@@ -0,0 +1,248 @@
1
+ """JSON-RPC 2.0 over a newline-delimited byte stream.
2
+
3
+ ACP is JSON-RPC 2.0, one message per line, spoken over a child process's stdin
4
+ and stdout. That is a small enough protocol to implement here, and implementing
5
+ it here is what lets this package keep an empty dependency list while still
6
+ talking to a real agent.
7
+
8
+ The peer knows nothing about ACP. It correlates requests with responses,
9
+ delivers notifications, answers incoming requests through a callback, and
10
+ reports the one failure a pipe has: the far side stopped talking. Everything
11
+ about sessions, prompts, and capabilities lives a layer up.
12
+
13
+ Both directions carry requests. The agent asks the client for permission, for
14
+ file contents, for a terminal; a peer that could only call outwards would be
15
+ half a connection.
16
+ """
17
+
18
+ import asyncio
19
+ import json
20
+ from collections.abc import Awaitable, Callable
21
+ from itertools import count
22
+
23
+ from langgraph_acp._json import JSONObject, JSONValue
24
+
25
+ #: The JSON-RPC error codes this peer sends, from the specification's list.
26
+ METHOD_NOT_FOUND = -32601
27
+ INTERNAL_ERROR = -32603
28
+
29
+
30
+ class JSONRPCError(Exception):
31
+ """An error *response*: the far side answered, and the answer was a refusal.
32
+
33
+ Distinct from a transport failure. This means the agent is alive and
34
+ declined; the caller above translates it into whichever `ACPError` describes
35
+ the operation that was refused.
36
+ """
37
+
38
+ def __init__(self, code: int, message: str, data: JSONValue = None) -> None:
39
+ super().__init__(f"{message} (code {code})")
40
+ self.code = code
41
+ self.message = message
42
+ self.data = data
43
+
44
+ def as_response_error(self) -> JSONObject:
45
+ error: JSONObject = {"code": self.code, "message": self.message}
46
+ if self.data is not None:
47
+ error["data"] = self.data
48
+ return error
49
+
50
+
51
+ class JSONRPCPeer:
52
+ """One end of a JSON-RPC connection over a pair of asyncio streams."""
53
+
54
+ def __init__(
55
+ self,
56
+ reader: asyncio.StreamReader,
57
+ writer: asyncio.StreamWriter,
58
+ *,
59
+ on_request: Callable[[str, JSONObject], Awaitable[JSONValue]],
60
+ on_notification: Callable[[str, JSONObject], None],
61
+ on_closed: Callable[[], BaseException],
62
+ on_eof: Callable[[], Awaitable[None]] | None = None,
63
+ ) -> None:
64
+ self._reader = reader
65
+ self._writer = writer
66
+ self._on_request = on_request
67
+ self._on_notification = on_notification
68
+ self._on_closed = on_closed
69
+ self._on_eof = on_eof
70
+ self._pending: dict[int, asyncio.Future[JSONValue]] = {}
71
+ self._ids = count()
72
+ self._answering: set[asyncio.Task[None]] = set()
73
+ self._reading: asyncio.Task[None] | None = None
74
+ self._closed = False
75
+
76
+ def start(self) -> None:
77
+ """Begin reading. Nothing is delivered or correlated until this runs."""
78
+ if self._reading is None:
79
+ self._reading = asyncio.create_task(self._read_until_eof())
80
+
81
+ async def request(self, method: str, params: JSONObject) -> JSONValue:
82
+ """Call the far side and wait for its answer.
83
+
84
+ Raises `JSONRPCError` if the answer is an error response, or whatever
85
+ `on_closed` produces if the connection ends before one arrives.
86
+ """
87
+ if self._closed:
88
+ raise self._on_closed()
89
+ message_id = next(self._ids)
90
+ future: asyncio.Future[JSONValue] = asyncio.get_running_loop().create_future()
91
+ self._pending[message_id] = future
92
+ try:
93
+ await self._send(
94
+ {
95
+ "jsonrpc": "2.0",
96
+ "id": message_id,
97
+ "method": method,
98
+ "params": params,
99
+ }
100
+ )
101
+ return await future
102
+ finally:
103
+ # Popped on every exit, cancellation included: an abandoned turn must
104
+ # not leave an entry that a late response would resolve.
105
+ self._pending.pop(message_id, None)
106
+
107
+ async def notify(self, method: str, params: JSONObject) -> None:
108
+ """Tell the far side something. There is no answer to wait for."""
109
+ await self._send({"jsonrpc": "2.0", "method": method, "params": params})
110
+
111
+ def notify_nowait(self, method: str, params: JSONObject) -> None:
112
+ """Queue a notification without awaiting the write.
113
+
114
+ For the one case that cannot await: unwinding a cancelled turn, where
115
+ every suspension point is another chance to be cancelled again. A
116
+ notification is a few hundred bytes and the transport buffers it, so the
117
+ write itself does not block; only backpressure would, and this skips it.
118
+ """
119
+ try:
120
+ self._write({"jsonrpc": "2.0", "method": method, "params": params})
121
+ except (ConnectionError, RuntimeError):
122
+ # The pipe is already gone, which is the outcome this was asking for.
123
+ pass
124
+
125
+ async def aclose(self) -> None:
126
+ """Stop reading, close the write side, and fail anything still pending."""
127
+ self._closed = True
128
+ if self._reading is not None:
129
+ self._reading.cancel()
130
+ self._reading = None
131
+ for task in tuple(self._answering):
132
+ task.cancel()
133
+ self._fail_pending(None)
134
+ if not self._writer.is_closing():
135
+ self._writer.close()
136
+ try:
137
+ await self._writer.wait_closed()
138
+ except (ConnectionError, BrokenPipeError):
139
+ pass
140
+
141
+ def _write(self, message: JSONObject) -> None:
142
+ self._writer.write(json.dumps(message).encode() + b"\n")
143
+
144
+ async def _send(self, message: JSONObject) -> None:
145
+ if self._closed or self._writer.is_closing():
146
+ raise self._on_closed()
147
+ try:
148
+ self._write(message)
149
+ await self._writer.drain()
150
+ except (ConnectionError, RuntimeError) as exc:
151
+ raise self._on_closed() from exc
152
+
153
+ async def _read_until_eof(self) -> None:
154
+ try:
155
+ await self._read()
156
+ except Exception:
157
+ # A transport failure and a clean EOF are the same event to a caller
158
+ # waiting on a request: no answer is coming. `on_closed` describes
159
+ # both, and it can see the exit status and stderr that explain it.
160
+ pass
161
+ finally:
162
+ self._closed = True
163
+ # Only on the natural path. Cancellation means `aclose` is already
164
+ # unwinding this connection and will fail what is pending itself.
165
+ if self._on_eof is not None:
166
+ await self._on_eof()
167
+ self._fail_pending(None)
168
+
169
+ async def _read(self) -> None:
170
+ while True:
171
+ line = await self._reader.readline()
172
+ if not line:
173
+ return
174
+ stripped = line.strip()
175
+ if not stripped:
176
+ continue
177
+ try:
178
+ message = json.loads(stripped)
179
+ except json.JSONDecodeError:
180
+ # Agents write to stdout for reasons other than the protocol.
181
+ # A line that is not a message cannot be answered or correlated,
182
+ # and killing the connection over it would be a worse answer.
183
+ continue
184
+ if isinstance(message, dict):
185
+ self._dispatch(message)
186
+
187
+ def _dispatch(self, message: dict[str, JSONValue]) -> None:
188
+ method = message.get("method")
189
+ if isinstance(method, str):
190
+ raw_params = message.get("params")
191
+ params: JSONObject = dict(raw_params) if isinstance(raw_params, dict) else {}
192
+ message_id = message.get("id")
193
+ if message_id is None:
194
+ self._on_notification(method, params)
195
+ else:
196
+ task = asyncio.create_task(self._answer(message_id, method, params))
197
+ self._answering.add(task)
198
+ task.add_done_callback(self._answering.discard)
199
+ return
200
+ self._resolve(message)
201
+
202
+ def _resolve(self, message: dict[str, JSONValue]) -> None:
203
+ message_id = message.get("id")
204
+ if not isinstance(message_id, int) or isinstance(message_id, bool):
205
+ return
206
+ future = self._pending.pop(message_id, None)
207
+ if future is None or future.done():
208
+ return
209
+ error = message.get("error")
210
+ if isinstance(error, dict):
211
+ code = error.get("code")
212
+ detail = error.get("message")
213
+ future.set_exception(
214
+ JSONRPCError(
215
+ code if isinstance(code, int) else INTERNAL_ERROR,
216
+ detail if isinstance(detail, str) else "the agent reported an error",
217
+ error.get("data"),
218
+ )
219
+ )
220
+ return
221
+ future.set_result(message.get("result"))
222
+
223
+ async def _answer(self, message_id: JSONValue, method: str, params: JSONObject) -> None:
224
+ try:
225
+ result = await self._on_request(method, params)
226
+ except asyncio.CancelledError:
227
+ raise
228
+ except JSONRPCError as exc:
229
+ reply: JSONObject = {"error": exc.as_response_error()}
230
+ except Exception as exc:
231
+ reply = {"error": {"code": INTERNAL_ERROR, "message": str(exc)}}
232
+ else:
233
+ reply = {"result": result}
234
+ try:
235
+ await self._send({"jsonrpc": "2.0", "id": message_id, **reply})
236
+ except Exception:
237
+ # Nothing is listening for this answer any more, and the request that
238
+ # provoked it has already failed on its own account.
239
+ pass
240
+
241
+ def _fail_pending(self, reason: BaseException | None) -> None:
242
+ for future in tuple(self._pending.values()):
243
+ if not future.done():
244
+ future.set_exception(reason or self._on_closed())
245
+ self._pending.clear()
246
+
247
+
248
+ __all__ = ["INTERNAL_ERROR", "METHOD_NOT_FOUND", "JSONRPCError", "JSONRPCPeer"]