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.
Files changed (41) hide show
  1. langgraph_acp-0.1.0/.gitignore +25 -0
  2. langgraph_acp-0.1.0/.python-version +1 -0
  3. langgraph_acp-0.1.0/PKG-INFO +131 -0
  4. langgraph_acp-0.1.0/README.md +114 -0
  5. langgraph_acp-0.1.0/build-constraints.txt +23 -0
  6. langgraph_acp-0.1.0/pyproject.toml +57 -0
  7. langgraph_acp-0.1.0/src/langgraph_acp/__init__.py +134 -0
  8. langgraph_acp-0.1.0/src/langgraph_acp/_json.py +136 -0
  9. langgraph_acp-0.1.0/src/langgraph_acp/_jsonrpc.py +248 -0
  10. langgraph_acp-0.1.0/src/langgraph_acp/_stdio.py +699 -0
  11. langgraph_acp-0.1.0/src/langgraph_acp/agent.py +198 -0
  12. langgraph_acp-0.1.0/src/langgraph_acp/client.py +170 -0
  13. langgraph_acp-0.1.0/src/langgraph_acp/config.py +84 -0
  14. langgraph_acp-0.1.0/src/langgraph_acp/continuation.py +137 -0
  15. langgraph_acp-0.1.0/src/langgraph_acp/elicitation.py +114 -0
  16. langgraph_acp-0.1.0/src/langgraph_acp/errors.py +106 -0
  17. langgraph_acp-0.1.0/src/langgraph_acp/events.py +138 -0
  18. langgraph_acp-0.1.0/src/langgraph_acp/node.py +88 -0
  19. langgraph_acp-0.1.0/src/langgraph_acp/permissions.py +175 -0
  20. langgraph_acp-0.1.0/src/langgraph_acp/providers/__init__.py +17 -0
  21. langgraph_acp-0.1.0/src/langgraph_acp/providers/claude.py +48 -0
  22. langgraph_acp-0.1.0/src/langgraph_acp/providers/codex.py +92 -0
  23. langgraph_acp-0.1.0/src/langgraph_acp/py.typed +0 -0
  24. langgraph_acp-0.1.0/src/langgraph_acp/result.py +141 -0
  25. langgraph_acp-0.1.0/src/langgraph_acp/session.py +130 -0
  26. langgraph_acp-0.1.0/src/langgraph_acp/store.py +127 -0
  27. langgraph_acp-0.1.0/src/langgraph_acp/workspace.py +52 -0
  28. langgraph_acp-0.1.0/tests/fake_agent.py +439 -0
  29. langgraph_acp-0.1.0/tests/test_adapter_compatibility.py +197 -0
  30. langgraph_acp-0.1.0/tests/test_agent.py +127 -0
  31. langgraph_acp-0.1.0/tests/test_client.py +629 -0
  32. langgraph_acp-0.1.0/tests/test_config.py +62 -0
  33. langgraph_acp-0.1.0/tests/test_errors.py +59 -0
  34. langgraph_acp-0.1.0/tests/test_events.py +161 -0
  35. langgraph_acp-0.1.0/tests/test_node.py +132 -0
  36. langgraph_acp-0.1.0/tests/test_public_api.py +101 -0
  37. langgraph_acp-0.1.0/tests/test_result.py +154 -0
  38. langgraph_acp-0.1.0/tests/test_session.py +54 -0
  39. langgraph_acp-0.1.0/tests/test_store.py +211 -0
  40. langgraph_acp-0.1.0/tests/test_workspace.py +45 -0
  41. 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
+ ]