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.
- langgraph_acp/__init__.py +134 -0
- langgraph_acp/_json.py +136 -0
- langgraph_acp/_jsonrpc.py +248 -0
- langgraph_acp/_stdio.py +699 -0
- langgraph_acp/agent.py +198 -0
- langgraph_acp/client.py +170 -0
- langgraph_acp/config.py +84 -0
- langgraph_acp/continuation.py +137 -0
- langgraph_acp/elicitation.py +114 -0
- langgraph_acp/errors.py +106 -0
- langgraph_acp/events.py +138 -0
- langgraph_acp/node.py +88 -0
- langgraph_acp/permissions.py +175 -0
- langgraph_acp/providers/__init__.py +17 -0
- langgraph_acp/providers/claude.py +48 -0
- langgraph_acp/providers/codex.py +92 -0
- langgraph_acp/py.typed +0 -0
- langgraph_acp/result.py +141 -0
- langgraph_acp/session.py +130 -0
- langgraph_acp/store.py +127 -0
- langgraph_acp/workspace.py +52 -0
- langgraph_acp-0.1.0.dist-info/METADATA +131 -0
- langgraph_acp-0.1.0.dist-info/RECORD +24 -0
- langgraph_acp-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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"]
|