langgraph-acp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- langgraph_acp-0.1.0/.gitignore +25 -0
- langgraph_acp-0.1.0/.python-version +1 -0
- langgraph_acp-0.1.0/PKG-INFO +131 -0
- langgraph_acp-0.1.0/README.md +114 -0
- langgraph_acp-0.1.0/build-constraints.txt +23 -0
- langgraph_acp-0.1.0/pyproject.toml +57 -0
- langgraph_acp-0.1.0/src/langgraph_acp/__init__.py +134 -0
- langgraph_acp-0.1.0/src/langgraph_acp/_json.py +136 -0
- langgraph_acp-0.1.0/src/langgraph_acp/_jsonrpc.py +248 -0
- langgraph_acp-0.1.0/src/langgraph_acp/_stdio.py +699 -0
- langgraph_acp-0.1.0/src/langgraph_acp/agent.py +198 -0
- langgraph_acp-0.1.0/src/langgraph_acp/client.py +170 -0
- langgraph_acp-0.1.0/src/langgraph_acp/config.py +84 -0
- langgraph_acp-0.1.0/src/langgraph_acp/continuation.py +137 -0
- langgraph_acp-0.1.0/src/langgraph_acp/elicitation.py +114 -0
- langgraph_acp-0.1.0/src/langgraph_acp/errors.py +106 -0
- langgraph_acp-0.1.0/src/langgraph_acp/events.py +138 -0
- langgraph_acp-0.1.0/src/langgraph_acp/node.py +88 -0
- langgraph_acp-0.1.0/src/langgraph_acp/permissions.py +175 -0
- langgraph_acp-0.1.0/src/langgraph_acp/providers/__init__.py +17 -0
- langgraph_acp-0.1.0/src/langgraph_acp/providers/claude.py +48 -0
- langgraph_acp-0.1.0/src/langgraph_acp/providers/codex.py +92 -0
- langgraph_acp-0.1.0/src/langgraph_acp/py.typed +0 -0
- langgraph_acp-0.1.0/src/langgraph_acp/result.py +141 -0
- langgraph_acp-0.1.0/src/langgraph_acp/session.py +130 -0
- langgraph_acp-0.1.0/src/langgraph_acp/store.py +127 -0
- langgraph_acp-0.1.0/src/langgraph_acp/workspace.py +52 -0
- langgraph_acp-0.1.0/tests/fake_agent.py +439 -0
- langgraph_acp-0.1.0/tests/test_adapter_compatibility.py +197 -0
- langgraph_acp-0.1.0/tests/test_agent.py +127 -0
- langgraph_acp-0.1.0/tests/test_client.py +629 -0
- langgraph_acp-0.1.0/tests/test_config.py +62 -0
- langgraph_acp-0.1.0/tests/test_errors.py +59 -0
- langgraph_acp-0.1.0/tests/test_events.py +161 -0
- langgraph_acp-0.1.0/tests/test_node.py +132 -0
- langgraph_acp-0.1.0/tests/test_public_api.py +101 -0
- langgraph_acp-0.1.0/tests/test_result.py +154 -0
- langgraph_acp-0.1.0/tests/test_session.py +54 -0
- langgraph_acp-0.1.0/tests/test_store.py +211 -0
- langgraph_acp-0.1.0/tests/test_workspace.py +45 -0
- langgraph_acp-0.1.0/uv.lock +1508 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
.venv/
|
|
4
|
+
uv.lock.bak
|
|
5
|
+
dist/
|
|
6
|
+
build/
|
|
7
|
+
*.egg-info/
|
|
8
|
+
.pytest_cache/
|
|
9
|
+
.ruff_cache/
|
|
10
|
+
.mypy_cache/
|
|
11
|
+
.DS_Store
|
|
12
|
+
node_modules/
|
|
13
|
+
*.tsbuildinfo
|
|
14
|
+
/conversations.sqlite3
|
|
15
|
+
# Where a graph workflow remembers how far it got, written next to wherever
|
|
16
|
+
# the server was started. Local state, like the conversation store above it.
|
|
17
|
+
/graph-state/
|
|
18
|
+
.claude/settings.local.json
|
|
19
|
+
.codex/
|
|
20
|
+
test-results/
|
|
21
|
+
playwright-report/
|
|
22
|
+
|
|
23
|
+
# Server-local secrets
|
|
24
|
+
.env
|
|
25
|
+
.env.*
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.14
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: langgraph-acp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Use ACP-compatible agents as first-class LangGraph nodes.
|
|
5
|
+
Project-URL: Homepage, https://github.com/OpenEngine/OpenEngine/tree/main/langgraph-acp
|
|
6
|
+
Project-URL: Repository, https://github.com/OpenEngine/OpenEngine
|
|
7
|
+
Project-URL: Issues, https://github.com/OpenEngine/OpenEngine/issues
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Keywords: acp,agent-client-protocol,agents,langgraph
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# langgraph-acp
|
|
19
|
+
|
|
20
|
+
Use [ACP](https://agentclientprotocol.com)-compatible agents — Codex, Claude,
|
|
21
|
+
and anything else that speaks the protocol — as first-class LangGraph nodes.
|
|
22
|
+
|
|
23
|
+
```text
|
|
24
|
+
LangGraph owns orchestration and workflow durability.
|
|
25
|
+
ACP owns the agent conversation and agent-side session state.
|
|
26
|
+
langgraph-acp owns the binding between the two.
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The design and the ticket sequence live in
|
|
30
|
+
[docs/langgraph-acp Architecture and Implementation Plan.md](https://github.com/OpenEngine/OpenEngine/blob/main/docs/langgraph-acp%20Architecture%20and%20Implementation%20Plan.md).
|
|
31
|
+
|
|
32
|
+
## Status
|
|
33
|
+
|
|
34
|
+
Tickets 1 to 4 of that plan: the core types, a connection to a real agent, the
|
|
35
|
+
store that remembers which conversation belongs to which node, and the first
|
|
36
|
+
working `ACPNode` end-to-end path.
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from langgraph_acp import ACPNode
|
|
40
|
+
|
|
41
|
+
result = await ACPNode(agent="codex")("Review this change")
|
|
42
|
+
print(result.message)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Available now:
|
|
46
|
+
|
|
47
|
+
| | |
|
|
48
|
+
| --- | --- |
|
|
49
|
+
| `ACPNode` | a LangGraph-compatible async callable that runs one ACP turn |
|
|
50
|
+
| `ACPAgentProvider`, `ACPAgentRegistry`, `default_registry` | names resolve to agents |
|
|
51
|
+
| `StdioACPProvider`, `CodexACPProvider` | how an agent is launched |
|
|
52
|
+
| `ACPClient`, `ACPCapabilities` | one live connection, and what it can do |
|
|
53
|
+
| `ACPSession` | one conversation, and the turns run in it |
|
|
54
|
+
| `ACPSessionSpec`, `ACPSessionStrategy` | which conversation a node speaks in |
|
|
55
|
+
| `ACPSessionStore`, `InMemoryACPSessionStore` | which ACP session a node's identity resolves to |
|
|
56
|
+
| `ACPWorkspace` | the filesystem context a session is given |
|
|
57
|
+
| `ACPConfig`, `ACPRequirements` | settings requested, capabilities demanded |
|
|
58
|
+
| `ACPEvent`, `ACPEventType` | streamed activity, normalized |
|
|
59
|
+
| `ACPResult`, `ACPUsage` | what a turn returns, and what it cost |
|
|
60
|
+
| `ACPError` and its subclasses | typed failures |
|
|
61
|
+
|
|
62
|
+
Registering another ACP agent is a command line rather than a class:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
default_registry().register(
|
|
66
|
+
StdioACPProvider(name="gemini", command=["gemini", "--experimental-acp"])
|
|
67
|
+
)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The minimal node takes its invocation input as the prompt and starts a fresh
|
|
71
|
+
session each time. Runtime prompt resolvers, session reuse, MCP injection, and
|
|
72
|
+
LangGraph event streaming arrive in the following tickets.
|
|
73
|
+
|
|
74
|
+
## Session bindings
|
|
75
|
+
|
|
76
|
+
`ACPSessionStore` maps a node's logical identity to the ACP session it speaks
|
|
77
|
+
in, so that a workflow can pick a conversation back up later:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
store = InMemoryACPSessionStore()
|
|
81
|
+
|
|
82
|
+
await store.put("pr-918", "reviewer", "sess_abc123")
|
|
83
|
+
await store.get("pr-918", "reviewer") # -> "sess_abc123"
|
|
84
|
+
await store.delete("pr-918", "reviewer")
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The identity is a pair, not a thread id, because one LangGraph thread routinely
|
|
88
|
+
runs several agents — an implementer and three reviewers on the same pull
|
|
89
|
+
request — and none of them may resume another's conversation.
|
|
90
|
+
|
|
91
|
+
**A store holds ACP session identifiers and nothing else.** No conversation
|
|
92
|
+
history, no agent messages, no tool history, no model context, no agent memory:
|
|
93
|
+
the ACP agent owns all of that and restores it itself when asked to load
|
|
94
|
+
`sess_abc123`. That is what makes a reply arriving on a webhook days later
|
|
95
|
+
resume a live conversation without reconstructing a transcript — and why the
|
|
96
|
+
store is the piece that has to be durable. `InMemoryACPSessionStore` is not; it
|
|
97
|
+
lives as long as its process, which is right for tests, examples, and
|
|
98
|
+
single-process graphs. The durable implementations arrive with Ticket 17 and
|
|
99
|
+
change nothing about the interface.
|
|
100
|
+
|
|
101
|
+
Two behaviours are placeholders that later tickets replace. Permission requests
|
|
102
|
+
are streamed as `acp.permission.requested` and then declined, because the policy
|
|
103
|
+
that could approve them does not exist yet; and the history an agent replays
|
|
104
|
+
while loading a session is dropped, because no turn is streaming when it
|
|
105
|
+
arrives.
|
|
106
|
+
|
|
107
|
+
## Development
|
|
108
|
+
|
|
109
|
+
A standalone distribution, deliberately outside the `uv` workspace at the root
|
|
110
|
+
of this repository: it depends on nothing OpenEngine ships, targets a newer
|
|
111
|
+
Python than the rest of the tree, and is intended to be published on its own.
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
cd langgraph-acp
|
|
115
|
+
uv run pytest
|
|
116
|
+
uv run mypy # strict, over src/ and tests/
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The package ships `py.typed`, so its annotations are a promise to a downstream
|
|
120
|
+
type checker; `mypy --strict` runs in CI on both supported interpreters to keep
|
|
121
|
+
it. Each field is annotated with what the constructor accepts, since
|
|
122
|
+
`__post_init__` normalizes — a `Path` becomes a `str`, `"reuse"` becomes
|
|
123
|
+
`ACPSessionStrategy.REUSE`.
|
|
124
|
+
|
|
125
|
+
The dependency list is empty and stays empty: ACP is JSON-RPC over a pipe, so
|
|
126
|
+
the client is stdlib `asyncio` written here rather than a second protocol
|
|
127
|
+
library in every application that installs this one. The client tests launch a
|
|
128
|
+
real child process — `tests/fake_agent.py`, an ACP agent that does nothing,
|
|
129
|
+
correctly — because the thing under test is a process boundary.
|
|
130
|
+
|
|
131
|
+
CI runs both only when something under `langgraph-acp/` changes.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# langgraph-acp
|
|
2
|
+
|
|
3
|
+
Use [ACP](https://agentclientprotocol.com)-compatible agents — Codex, Claude,
|
|
4
|
+
and anything else that speaks the protocol — as first-class LangGraph nodes.
|
|
5
|
+
|
|
6
|
+
```text
|
|
7
|
+
LangGraph owns orchestration and workflow durability.
|
|
8
|
+
ACP owns the agent conversation and agent-side session state.
|
|
9
|
+
langgraph-acp owns the binding between the two.
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
The design and the ticket sequence live in
|
|
13
|
+
[docs/langgraph-acp Architecture and Implementation Plan.md](https://github.com/OpenEngine/OpenEngine/blob/main/docs/langgraph-acp%20Architecture%20and%20Implementation%20Plan.md).
|
|
14
|
+
|
|
15
|
+
## Status
|
|
16
|
+
|
|
17
|
+
Tickets 1 to 4 of that plan: the core types, a connection to a real agent, the
|
|
18
|
+
store that remembers which conversation belongs to which node, and the first
|
|
19
|
+
working `ACPNode` end-to-end path.
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from langgraph_acp import ACPNode
|
|
23
|
+
|
|
24
|
+
result = await ACPNode(agent="codex")("Review this change")
|
|
25
|
+
print(result.message)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Available now:
|
|
29
|
+
|
|
30
|
+
| | |
|
|
31
|
+
| --- | --- |
|
|
32
|
+
| `ACPNode` | a LangGraph-compatible async callable that runs one ACP turn |
|
|
33
|
+
| `ACPAgentProvider`, `ACPAgentRegistry`, `default_registry` | names resolve to agents |
|
|
34
|
+
| `StdioACPProvider`, `CodexACPProvider` | how an agent is launched |
|
|
35
|
+
| `ACPClient`, `ACPCapabilities` | one live connection, and what it can do |
|
|
36
|
+
| `ACPSession` | one conversation, and the turns run in it |
|
|
37
|
+
| `ACPSessionSpec`, `ACPSessionStrategy` | which conversation a node speaks in |
|
|
38
|
+
| `ACPSessionStore`, `InMemoryACPSessionStore` | which ACP session a node's identity resolves to |
|
|
39
|
+
| `ACPWorkspace` | the filesystem context a session is given |
|
|
40
|
+
| `ACPConfig`, `ACPRequirements` | settings requested, capabilities demanded |
|
|
41
|
+
| `ACPEvent`, `ACPEventType` | streamed activity, normalized |
|
|
42
|
+
| `ACPResult`, `ACPUsage` | what a turn returns, and what it cost |
|
|
43
|
+
| `ACPError` and its subclasses | typed failures |
|
|
44
|
+
|
|
45
|
+
Registering another ACP agent is a command line rather than a class:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
default_registry().register(
|
|
49
|
+
StdioACPProvider(name="gemini", command=["gemini", "--experimental-acp"])
|
|
50
|
+
)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The minimal node takes its invocation input as the prompt and starts a fresh
|
|
54
|
+
session each time. Runtime prompt resolvers, session reuse, MCP injection, and
|
|
55
|
+
LangGraph event streaming arrive in the following tickets.
|
|
56
|
+
|
|
57
|
+
## Session bindings
|
|
58
|
+
|
|
59
|
+
`ACPSessionStore` maps a node's logical identity to the ACP session it speaks
|
|
60
|
+
in, so that a workflow can pick a conversation back up later:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
store = InMemoryACPSessionStore()
|
|
64
|
+
|
|
65
|
+
await store.put("pr-918", "reviewer", "sess_abc123")
|
|
66
|
+
await store.get("pr-918", "reviewer") # -> "sess_abc123"
|
|
67
|
+
await store.delete("pr-918", "reviewer")
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The identity is a pair, not a thread id, because one LangGraph thread routinely
|
|
71
|
+
runs several agents — an implementer and three reviewers on the same pull
|
|
72
|
+
request — and none of them may resume another's conversation.
|
|
73
|
+
|
|
74
|
+
**A store holds ACP session identifiers and nothing else.** No conversation
|
|
75
|
+
history, no agent messages, no tool history, no model context, no agent memory:
|
|
76
|
+
the ACP agent owns all of that and restores it itself when asked to load
|
|
77
|
+
`sess_abc123`. That is what makes a reply arriving on a webhook days later
|
|
78
|
+
resume a live conversation without reconstructing a transcript — and why the
|
|
79
|
+
store is the piece that has to be durable. `InMemoryACPSessionStore` is not; it
|
|
80
|
+
lives as long as its process, which is right for tests, examples, and
|
|
81
|
+
single-process graphs. The durable implementations arrive with Ticket 17 and
|
|
82
|
+
change nothing about the interface.
|
|
83
|
+
|
|
84
|
+
Two behaviours are placeholders that later tickets replace. Permission requests
|
|
85
|
+
are streamed as `acp.permission.requested` and then declined, because the policy
|
|
86
|
+
that could approve them does not exist yet; and the history an agent replays
|
|
87
|
+
while loading a session is dropped, because no turn is streaming when it
|
|
88
|
+
arrives.
|
|
89
|
+
|
|
90
|
+
## Development
|
|
91
|
+
|
|
92
|
+
A standalone distribution, deliberately outside the `uv` workspace at the root
|
|
93
|
+
of this repository: it depends on nothing OpenEngine ships, targets a newer
|
|
94
|
+
Python than the rest of the tree, and is intended to be published on its own.
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
cd langgraph-acp
|
|
98
|
+
uv run pytest
|
|
99
|
+
uv run mypy # strict, over src/ and tests/
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The package ships `py.typed`, so its annotations are a promise to a downstream
|
|
103
|
+
type checker; `mypy --strict` runs in CI on both supported interpreters to keep
|
|
104
|
+
it. Each field is annotated with what the constructor accepts, since
|
|
105
|
+
`__post_init__` normalizes — a `Path` becomes a `str`, `"reuse"` becomes
|
|
106
|
+
`ACPSessionStrategy.REUSE`.
|
|
107
|
+
|
|
108
|
+
The dependency list is empty and stays empty: ACP is JSON-RPC over a pipe, so
|
|
109
|
+
the client is stdlib `asyncio` written here rather than a second protocol
|
|
110
|
+
library in every application that installs this one. The client tests launch a
|
|
111
|
+
real child process — `tests/fake_agent.py`, an ACP agent that does nothing,
|
|
112
|
+
correctly — because the thing under test is a process boundary.
|
|
113
|
+
|
|
114
|
+
CI runs both only when something under `langgraph-acp/` changes.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Hash-locked build backend for the PyPI release of langgraph-acp. Regenerate with:
|
|
2
|
+
# echo 'hatchling==<version>' | uv pip compile - --generate-hashes --universal \
|
|
3
|
+
# --python-version 3.11 --no-header -o build-constraints.txt
|
|
4
|
+
hatchling==1.27.0 \
|
|
5
|
+
--hash=sha256:971c296d9819abb3811112fc52c7a9751c8d381898f36533bb16f9791e941fd6 \
|
|
6
|
+
--hash=sha256:d3a2f3567c4f926ea39849cdf924c7e99e6686c9c8e288ae1037c8fa2a5d937b
|
|
7
|
+
# via build-system.requires
|
|
8
|
+
packaging==26.3 \
|
|
9
|
+
--hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \
|
|
10
|
+
--hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
|
|
11
|
+
# via hatchling
|
|
12
|
+
pathspec==1.1.1 \
|
|
13
|
+
--hash=sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a \
|
|
14
|
+
--hash=sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189
|
|
15
|
+
# via hatchling
|
|
16
|
+
pluggy==1.6.0 \
|
|
17
|
+
--hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
|
|
18
|
+
--hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
|
|
19
|
+
# via hatchling
|
|
20
|
+
trove-classifiers==2026.9.21.13 \
|
|
21
|
+
--hash=sha256:0a9ebc8d4e2f3e8a22848c5258033035bec17a3012ac3fea16dbaa764489eb71 \
|
|
22
|
+
--hash=sha256:8b1ff4f9c191b1040b71c37f1e445ab99732911e3cd91de52838453a854d7a17
|
|
23
|
+
# via hatchling
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "langgraph-acp"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Use ACP-compatible agents as first-class LangGraph nodes."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "Apache-2.0"
|
|
8
|
+
keywords = ["acp", "agent-client-protocol", "agents", "langgraph"]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 3 - Alpha",
|
|
11
|
+
"Framework :: AsyncIO",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Typing :: Typed",
|
|
15
|
+
]
|
|
16
|
+
# INVARIANT: no dependencies. ACP is JSON-RPC over a pipe, so the client is
|
|
17
|
+
# stdlib asyncio written here rather than a second protocol library in every
|
|
18
|
+
# application that installs this one; LangGraph arrives with the node, and
|
|
19
|
+
# naming an agent is confined to `langgraph_acp.providers`.
|
|
20
|
+
# Enforced by tests/test_public_api.py.
|
|
21
|
+
dependencies = []
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/OpenEngine/OpenEngine/tree/main/langgraph-acp"
|
|
25
|
+
Repository = "https://github.com/OpenEngine/OpenEngine"
|
|
26
|
+
Issues = "https://github.com/OpenEngine/OpenEngine/issues"
|
|
27
|
+
|
|
28
|
+
[build-system]
|
|
29
|
+
requires = ["hatchling>=1.27"]
|
|
30
|
+
build-backend = "hatchling.build"
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel]
|
|
33
|
+
packages = ["src/langgraph_acp"]
|
|
34
|
+
|
|
35
|
+
[dependency-groups]
|
|
36
|
+
dev = ["langgraph>=1.0", "mypy>=1.15", "pytest>=8.0"]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
testpaths = ["tests"]
|
|
40
|
+
# Live adapter runs are deselected by default and opted into with
|
|
41
|
+
# `-m compatibility`, which is what the scheduled compatibility workflow does.
|
|
42
|
+
# They install the adapter from npm and talk to a real agent, so a default run
|
|
43
|
+
# that included them would mean "did we break something, or is the registry
|
|
44
|
+
# slow today?". The same convention, and the same reasoning, as the root
|
|
45
|
+
# `pyproject.toml`.
|
|
46
|
+
addopts = "-q -m 'not compatibility'"
|
|
47
|
+
markers = [
|
|
48
|
+
"compatibility: launches the real ACP adapter; scheduled rather than per-PR",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
[tool.mypy]
|
|
52
|
+
# `py.typed` promises a downstream type checker that these annotations are
|
|
53
|
+
# true, and only a type checker run against them keeps that promise. The tests
|
|
54
|
+
# are checked too: they are where the spellings this package documents are
|
|
55
|
+
# written out, so an annotation that rejects a documented call fails here.
|
|
56
|
+
strict = true
|
|
57
|
+
files = ["src", "tests"]
|
|
@@ -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
|
+
]
|
|
@@ -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
|
+
]
|