slopper 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.
- slopper-0.1.0.dist-info/METADATA +115 -0
- slopper-0.1.0.dist-info/RECORD +34 -0
- slopper-0.1.0.dist-info/WHEEL +5 -0
- slopper-0.1.0.dist-info/entry_points.txt +4 -0
- slopper-0.1.0.dist-info/top_level.txt +1 -0
- slopper_env/__init__.py +0 -0
- slopper_env/__main__.py +7 -0
- slopper_env/adapters/__init__.py +0 -0
- slopper_env/adapters/base.py +44 -0
- slopper_env/adapters/claude_code.py +184 -0
- slopper_env/adapters/codex.py +220 -0
- slopper_env/agent.py +159 -0
- slopper_env/backends/__init__.py +21 -0
- slopper_env/backends/base.py +75 -0
- slopper_env/backends/claude.py +208 -0
- slopper_env/backends/codex.py +451 -0
- slopper_env/cli.py +317 -0
- slopper_env/config.py +98 -0
- slopper_env/constants.py +18 -0
- slopper_env/devtools.py +208 -0
- slopper_env/doc.py +1446 -0
- slopper_env/folio_mcp.py +78 -0
- slopper_env/identity.py +82 -0
- slopper_env/ids.py +28 -0
- slopper_env/lock.py +61 -0
- slopper_env/loop.py +227 -0
- slopper_env/main.py +348 -0
- slopper_env/models.py +138 -0
- slopper_env/ordering.py +128 -0
- slopper_env/registration.py +135 -0
- slopper_env/runner.py +207 -0
- slopper_env/service.py +273 -0
- slopper_env/sync.py +231 -0
- slopper_env/workspace.py +307 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: slopper
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Run Claude Code and Codex agent sessions for Folio on your own machine.
|
|
5
|
+
Keywords: agents,claude-code,codex,folio
|
|
6
|
+
Classifier: Development Status :: 3 - Alpha
|
|
7
|
+
Classifier: Environment :: Console
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Operating System :: MacOS
|
|
10
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: pycrdt>=0.12.0
|
|
18
|
+
Requires-Dist: claude-agent-sdk<0.3,>=0.2.152
|
|
19
|
+
Requires-Dist: openai-codex-cli-bin<0.157,>=0.156
|
|
20
|
+
Requires-Dist: httpx>=0.28.0
|
|
21
|
+
Requires-Dist: websockets>=13.0
|
|
22
|
+
Requires-Dist: pydantic-settings>=2.2.0
|
|
23
|
+
Requires-Dist: python-json-logger>=2.0.0
|
|
24
|
+
|
|
25
|
+
# slopper
|
|
26
|
+
|
|
27
|
+
Run Folio agent sessions — Claude Code or Codex — on your own machine.
|
|
28
|
+
|
|
29
|
+
`slopper` is the environment a Folio session runs in. It registers with your
|
|
30
|
+
Folio server, picks up the sessions assigned to it, checks out the project's
|
|
31
|
+
repositories into a workspace per session, and runs the agent there. Every
|
|
32
|
+
tool call that needs your approval shows up in the session in Folio; the
|
|
33
|
+
agent waits until you answer.
|
|
34
|
+
|
|
35
|
+
Runs on **macOS** and **Linux**, Python 3.11+. Both agent CLIs are installed
|
|
36
|
+
with the package.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
uv tool install slopper # or: pipx install slopper
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Set up
|
|
45
|
+
|
|
46
|
+
1. **Sign the agents in**, once per machine, as the user the service will run as:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
claude login # Claude Code
|
|
50
|
+
codex login # Codex (optional)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
2. **Point it at Folio** with an API key from Folio's settings:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
slopper configure --server https://api.folio.example.com --token <api-key>
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
This writes `~/.slopper/config.toml`, readable only by you.
|
|
60
|
+
|
|
61
|
+
3. **Check everything**:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
slopper doctor
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
4. **Run it as a service** — started now, at every login, and again if it crashes:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
slopper service install
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
- **macOS:** a LaunchAgent (`~/Library/LaunchAgents/slopper.plist`) in your
|
|
74
|
+
login session, so it can use the Claude login in your Keychain.
|
|
75
|
+
Logs: `~/Library/Logs/slopper/slopper.log`.
|
|
76
|
+
- **Linux:** a systemd user unit (`~/.config/systemd/user/slopper.service`).
|
|
77
|
+
Logs: `journalctl --user -u slopper`. To keep it running while you are
|
|
78
|
+
logged out (a headless machine): `sudo loginctl enable-linger $USER`.
|
|
79
|
+
|
|
80
|
+
## Commands
|
|
81
|
+
|
|
82
|
+
| Command | |
|
|
83
|
+
|---|---|
|
|
84
|
+
| `slopper configure` | Write `~/.slopper/config.toml` (`--server`, `--token`, `--env-id`, `--workspace`) |
|
|
85
|
+
| `slopper doctor` | Check the config, the server, the key, git, and both agents' logins |
|
|
86
|
+
| `slopper service install` · `uninstall` | Install or remove the service |
|
|
87
|
+
| `slopper service start` · `stop` · `restart` · `status` | Control it |
|
|
88
|
+
| `slopper service logs [-f]` | Show (or follow) its log |
|
|
89
|
+
| `slopper run` | Run in the foreground instead of as a service |
|
|
90
|
+
|
|
91
|
+
Only one environment runs per user account; a second `slopper run` refuses to
|
|
92
|
+
start while the service is running.
|
|
93
|
+
|
|
94
|
+
## Upgrade
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
uv tool upgrade slopper && slopper service restart
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The service runs the installed interpreter by path, so it picks up the new
|
|
101
|
+
version on restart — there is nothing to reinstall.
|
|
102
|
+
|
|
103
|
+
## Configuration
|
|
104
|
+
|
|
105
|
+
`~/.slopper/config.toml` holds the settings; environment variables of the
|
|
106
|
+
same name override it.
|
|
107
|
+
|
|
108
|
+
| Setting | Default | |
|
|
109
|
+
|---|---|---|
|
|
110
|
+
| `SLOPPER_SERVER_URL` | `http://localhost:8000` | Folio API |
|
|
111
|
+
| `SLOPPER_TOKEN` | — | API key this environment registers with |
|
|
112
|
+
| `SLOPPER_ENV_ID` | generated once, kept in `~/.slopper/identity.toml` | This machine's name in Folio. Must stay stable: sessions are bound to it |
|
|
113
|
+
| `SLOPPER_WORKSPACE_ROOT` | `~/.slopper/workspaces` | One directory per session |
|
|
114
|
+
| `SLOPPER_CLONE_SINCE` | `1.month.ago` | How much git history a checkout carries; empty for all |
|
|
115
|
+
| `SLOPPER_CODEX_BIN` | the bundled one | A different `codex` binary |
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
slopper_env/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
slopper_env/__main__.py,sha256=sQ6a1m5lUARAT4diQ1vWB8Q45Yf1MBkdiTss4gMy6-0,132
|
|
3
|
+
slopper_env/agent.py,sha256=PKnsx8bynTskyUyrMKYKxXBHTfVcvSDxJSofhlFE_Js,6642
|
|
4
|
+
slopper_env/cli.py,sha256=mZ4uL9MBrwLFNbhvdnvoQeiJOjUNd2lDaIknf6bfmYo,11392
|
|
5
|
+
slopper_env/config.py,sha256=-kmfqpsYNo3AN3ZL9m0kQlCn1Ak5ovaBwsbfbqQzs5c,4064
|
|
6
|
+
slopper_env/constants.py,sha256=h009BxGnp2A-SyImMWfyyB0AIiQ6tgnU6zQud9oRqTE,660
|
|
7
|
+
slopper_env/devtools.py,sha256=mTPMZlPtUWsqMPBpC8r9OY5WU2b4cuYIHxrtkol5_xY,7021
|
|
8
|
+
slopper_env/doc.py,sha256=m7Y0lbJjMY7YAJ5llwmiw9LhnJ7P1EIDEDq4qDtP7-M,59804
|
|
9
|
+
slopper_env/folio_mcp.py,sha256=3LCgnLO_3_pYHVgvwT_GsUww6VG1_eAMRI1IofAyOFE,2813
|
|
10
|
+
slopper_env/identity.py,sha256=8cPZ77kCX1QufJpLkN5jWqZb26dswibGoFeD5-aIW-0,2707
|
|
11
|
+
slopper_env/ids.py,sha256=PiDUwk7MZEo1ULNn7UJwWf8H8UNnAMsFG3l9SsrOCD8,858
|
|
12
|
+
slopper_env/lock.py,sha256=gylCYceT1Dvo2q6lFmTpUBYWqvKIVp-Y3A_TpiDJgoM,1882
|
|
13
|
+
slopper_env/loop.py,sha256=w2MXysbGJ4KlAgA_7KDtHr1R36bZgEZdo-cqTWt_r2k,8865
|
|
14
|
+
slopper_env/main.py,sha256=39s7o3jKeg7LEUf5yFr-4-vi7-lGq93xwCRhcoKx_1c,13420
|
|
15
|
+
slopper_env/models.py,sha256=KadlFWHBujTtNZD26f0cjvns-Gmob0sZn9pfblt4sTs,4951
|
|
16
|
+
slopper_env/ordering.py,sha256=A7pWh1DkXDrLC9OZaS_dl3wXBeXijApTWMo8d84VmzA,4884
|
|
17
|
+
slopper_env/registration.py,sha256=yaD-KjyfGSLeoFso2RVb7HKg51GiPIgPsiNMwFTRIYs,4833
|
|
18
|
+
slopper_env/runner.py,sha256=aiaAAptSO4rwtkvcvfdKhyZaQyzAmf2GsBnpvnoFW-M,8411
|
|
19
|
+
slopper_env/service.py,sha256=c6f7ebkAxzd_WhZQLeI2ySQ3CBYZqez-c2cR6RVcLQk,8940
|
|
20
|
+
slopper_env/sync.py,sha256=bZD1lLekKRM8Mr2_T8KmY4zbIq15zZTP6I4ZYbAukj4,8364
|
|
21
|
+
slopper_env/workspace.py,sha256=996xqKQ7uoCgFr5IaYCilQ8kRoO4Fu_IaSjWs_AX4XA,13331
|
|
22
|
+
slopper_env/adapters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
23
|
+
slopper_env/adapters/base.py,sha256=xhhvq4qADuA-N2SDPkh1LQ5ljBY3r0QCBT4I5xwYdO4,1386
|
|
24
|
+
slopper_env/adapters/claude_code.py,sha256=nB7Ylb6zg8V5hPfYscGD1-56lRNDyA24XcJaPvIyP08,6807
|
|
25
|
+
slopper_env/adapters/codex.py,sha256=_D5gJZJ9n-6VVRLu4tLJoA65JL7R1VFKNI8B88NxXkY,8425
|
|
26
|
+
slopper_env/backends/__init__.py,sha256=mpcZ7wXN910ZCq3hvzf6LiynPEd-D6Mu1v8ncMsJBv4,719
|
|
27
|
+
slopper_env/backends/base.py,sha256=Uz3c-awxLYpb8NuLnPPUUS3oaE9PSOBEwLT-TB2-xv8,2690
|
|
28
|
+
slopper_env/backends/claude.py,sha256=9oC3FDY8efFSOZFJwAbwzfS4ebjrgPPvLDybWFR_Awk,8341
|
|
29
|
+
slopper_env/backends/codex.py,sha256=IE7NXo_7C3K3zU7mKtKocdqYQ9p67Sl5cx9KiVEIEOA,18153
|
|
30
|
+
slopper-0.1.0.dist-info/METADATA,sha256=DUkVAP92RMxiPnUJD8X_QvFV0ldbvKCpLr-u8bkRFi4,4099
|
|
31
|
+
slopper-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
32
|
+
slopper-0.1.0.dist-info/entry_points.txt,sha256=GmIIHP2kAcn_QmEGsMYzuyOKBMgYGutw4lg79zjbMvI,128
|
|
33
|
+
slopper-0.1.0.dist-info/top_level.txt,sha256=WqIoIhel29y542bultciwMbCXyXonN5B8atEYBy-NgU,12
|
|
34
|
+
slopper-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
slopper_env
|
slopper_env/__init__.py
ADDED
|
File without changes
|
slopper_env/__main__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""The provider seam.
|
|
2
|
+
|
|
3
|
+
This interface is the only place in the environment where a provider is
|
|
4
|
+
named. ``TurnRunner``, ``ConversationLoop``, the document, and the UI all
|
|
5
|
+
operate on canonical items alone.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any, Sequence
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class TurnEnd:
|
|
17
|
+
"""The provider's account of how a turn finished, in neutral terms."""
|
|
18
|
+
|
|
19
|
+
#: The provider's own session (Claude) or thread (Codex) id, recorded so
|
|
20
|
+
#: a later attach can resume it.
|
|
21
|
+
session_id: str | None
|
|
22
|
+
#: The turn ended on a tool call waiting for a human, to be resumed.
|
|
23
|
+
deferred: bool = False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AgentAdapter(ABC):
|
|
27
|
+
kind: str
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def from_provider(self, message: Any) -> list[dict]:
|
|
31
|
+
"""Translate one provider message into canonical items.
|
|
32
|
+
|
|
33
|
+
Returns a list because a single provider message may carry several
|
|
34
|
+
canonical items, and because some provider messages (usage reports,
|
|
35
|
+
lifecycle chatter) carry none.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def turn_end(self, message: Any) -> TurnEnd | None:
|
|
40
|
+
"""The turn's outcome if ``message`` is the one that ends it."""
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def to_provider(self, items: Sequence[dict]) -> Any:
|
|
44
|
+
"""Translate canonical history into a provider payload."""
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Claude Code adapter.
|
|
2
|
+
|
|
3
|
+
Near-identity in the ingest direction: the canonical content model is
|
|
4
|
+
Anthropic-shaped, and ``claude_agent_sdk`` emits exactly those blocks.
|
|
5
|
+
|
|
6
|
+
The one restructuring happens on egress. Slopper stores tool output under
|
|
7
|
+
``role="tool"``; the Messages API has no such role and requires it to travel
|
|
8
|
+
inside a user message. That re-encoding lives here and nowhere else, so the
|
|
9
|
+
stored transcript never claims a user said something they did not.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any, Sequence
|
|
15
|
+
|
|
16
|
+
import claude_agent_sdk as sdk
|
|
17
|
+
|
|
18
|
+
from slopper_env.adapters.base import AgentAdapter, TurnEnd
|
|
19
|
+
from slopper_env.ids import new_id
|
|
20
|
+
from slopper_env.models import BlockType, ItemType, NON_CONTENT_ITEM_TYPES, Role
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ClaudeCodeAdapter(AgentAdapter):
|
|
24
|
+
kind = "claude-code"
|
|
25
|
+
|
|
26
|
+
# ------------------------------------------------------------- ingest
|
|
27
|
+
|
|
28
|
+
def from_provider(self, message: Any) -> list[dict]:
|
|
29
|
+
if isinstance(message, sdk.AssistantMessage):
|
|
30
|
+
return self._assistant(message)
|
|
31
|
+
if isinstance(message, sdk.UserMessage):
|
|
32
|
+
return self._tool_results(message)
|
|
33
|
+
if isinstance(message, sdk.ResultMessage):
|
|
34
|
+
return [] # usage/cost is attached to the run, not a transcript item
|
|
35
|
+
return []
|
|
36
|
+
|
|
37
|
+
def turn_end(self, message: Any) -> TurnEnd | None:
|
|
38
|
+
"""The session id rides on the terminal ResultMessage and nowhere else."""
|
|
39
|
+
if not isinstance(message, sdk.ResultMessage):
|
|
40
|
+
return None
|
|
41
|
+
return TurnEnd(
|
|
42
|
+
session_id=getattr(message, "session_id", None),
|
|
43
|
+
deferred=getattr(message, "stop_reason", None) == "tool_deferred",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def _assistant(self, message: sdk.AssistantMessage) -> list[dict]:
|
|
47
|
+
content = [self._block(block) for block in message.content]
|
|
48
|
+
content = [block for block in content if block is not None]
|
|
49
|
+
if not content:
|
|
50
|
+
return []
|
|
51
|
+
return [
|
|
52
|
+
{
|
|
53
|
+
"id": new_id(),
|
|
54
|
+
"type": ItemType.MESSAGE,
|
|
55
|
+
"role": Role.ASSISTANT,
|
|
56
|
+
"model": getattr(message, "model", None),
|
|
57
|
+
"content": content,
|
|
58
|
+
}
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
def _tool_results(self, message: sdk.UserMessage) -> list[dict]:
|
|
62
|
+
"""Tool output arrives from the SDK as a UserMessage.
|
|
63
|
+
|
|
64
|
+
That is the wire format's constraint, not a fact about who produced
|
|
65
|
+
it, so it is stored under ``role="tool"``.
|
|
66
|
+
"""
|
|
67
|
+
if not isinstance(message.content, list):
|
|
68
|
+
return []
|
|
69
|
+
blocks = [
|
|
70
|
+
self._block(block)
|
|
71
|
+
for block in message.content
|
|
72
|
+
if isinstance(block, sdk.ToolResultBlock)
|
|
73
|
+
]
|
|
74
|
+
blocks = [block for block in blocks if block is not None]
|
|
75
|
+
if not blocks:
|
|
76
|
+
return []
|
|
77
|
+
return [
|
|
78
|
+
{
|
|
79
|
+
"id": new_id(),
|
|
80
|
+
"type": ItemType.MESSAGE,
|
|
81
|
+
"role": Role.TOOL,
|
|
82
|
+
"content": blocks,
|
|
83
|
+
}
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
def _block(self, block: Any) -> dict | None:
|
|
87
|
+
if isinstance(block, sdk.TextBlock):
|
|
88
|
+
return {"type": BlockType.TEXT, "text": block.text}
|
|
89
|
+
|
|
90
|
+
if isinstance(block, sdk.ThinkingBlock):
|
|
91
|
+
# `signature` must survive verbatim: Claude requires it back to
|
|
92
|
+
# accept a thinking block across tool-use turns. Never let a
|
|
93
|
+
# "normalize the block" helper drop it.
|
|
94
|
+
return {
|
|
95
|
+
"type": BlockType.THINKING,
|
|
96
|
+
"text": block.thinking,
|
|
97
|
+
"signature": block.signature,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if isinstance(block, sdk.ToolUseBlock):
|
|
101
|
+
return {
|
|
102
|
+
"type": BlockType.TOOL_USE,
|
|
103
|
+
"id": block.id,
|
|
104
|
+
"name": block.name,
|
|
105
|
+
"input": block.input,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if isinstance(block, sdk.ToolResultBlock):
|
|
109
|
+
return {
|
|
110
|
+
"type": BlockType.TOOL_RESULT,
|
|
111
|
+
"tool_use_id": block.tool_use_id,
|
|
112
|
+
"content": block.content,
|
|
113
|
+
"is_error": bool(block.is_error),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
# ------------------------------------------------------------- egress
|
|
119
|
+
|
|
120
|
+
def to_provider(self, items: Sequence[dict]) -> list[dict]:
|
|
121
|
+
"""Rebuild Anthropic-format history from canonical items.
|
|
122
|
+
|
|
123
|
+
Not on the hot path -- ``ClaudeSDKClient`` owns session history, so a
|
|
124
|
+
normal turn sends new input only. This runs for cold resume.
|
|
125
|
+
|
|
126
|
+
Items that are transcript metadata rather than conversation content
|
|
127
|
+
are dropped here. Replaying an error banner would have the agent
|
|
128
|
+
responding to its own infrastructure failure as though the user had
|
|
129
|
+
reported it.
|
|
130
|
+
"""
|
|
131
|
+
out: list[dict] = []
|
|
132
|
+
for item in items:
|
|
133
|
+
item_type = item.get("type")
|
|
134
|
+
if item_type in NON_CONTENT_ITEM_TYPES:
|
|
135
|
+
continue
|
|
136
|
+
if item_type == ItemType.RUN:
|
|
137
|
+
out.extend(self.to_provider(item.get("items") or []))
|
|
138
|
+
continue
|
|
139
|
+
if item_type != ItemType.MESSAGE:
|
|
140
|
+
continue
|
|
141
|
+
if item.get("is_meta") and item.get("role") == Role.SYSTEM:
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
role = item.get("role")
|
|
145
|
+
content = [self._to_wire(b) for b in (item.get("content") or [])]
|
|
146
|
+
content = [b for b in content if b is not None]
|
|
147
|
+
if not content:
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
# The Messages API has no tool role; tool output rides in a user
|
|
151
|
+
# message. Storage keeps them distinct, the wire does not.
|
|
152
|
+
wire_role = "user" if role == Role.TOOL else role
|
|
153
|
+
if wire_role not in ("user", "assistant"):
|
|
154
|
+
continue
|
|
155
|
+
out.append({"role": wire_role, "content": content})
|
|
156
|
+
return out
|
|
157
|
+
|
|
158
|
+
def _to_wire(self, block: dict) -> dict | None:
|
|
159
|
+
kind = block.get("type")
|
|
160
|
+
if kind == BlockType.TEXT:
|
|
161
|
+
return {"type": "text", "text": str(block.get("text", ""))}
|
|
162
|
+
if kind == BlockType.THINKING:
|
|
163
|
+
return {
|
|
164
|
+
"type": "thinking",
|
|
165
|
+
"thinking": block.get("text", ""),
|
|
166
|
+
"signature": block.get("signature", ""),
|
|
167
|
+
}
|
|
168
|
+
if kind == BlockType.TOOL_USE:
|
|
169
|
+
return {
|
|
170
|
+
"type": "tool_use",
|
|
171
|
+
"id": block.get("id"),
|
|
172
|
+
"name": block.get("name"),
|
|
173
|
+
"input": block.get("input") or {},
|
|
174
|
+
}
|
|
175
|
+
if kind == BlockType.TOOL_RESULT:
|
|
176
|
+
wire = {
|
|
177
|
+
"type": "tool_result",
|
|
178
|
+
"tool_use_id": block.get("tool_use_id"),
|
|
179
|
+
"content": block.get("content"),
|
|
180
|
+
}
|
|
181
|
+
if block.get("is_error"):
|
|
182
|
+
wire["is_error"] = True
|
|
183
|
+
return wire
|
|
184
|
+
return None
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Codex adapter.
|
|
2
|
+
|
|
3
|
+
Codex reports a turn as *items* -- an agent message, a reasoning summary, a
|
|
4
|
+
command it ran, a file change, an MCP call -- each announced by
|
|
5
|
+
``item/started`` and finished by ``item/completed`` over the app-server
|
|
6
|
+
protocol. This turns them into the same canonical items the Claude adapter
|
|
7
|
+
produces, so the document and the UI stay provider-blind:
|
|
8
|
+
|
|
9
|
+
agentMessage -> assistant text
|
|
10
|
+
reasoning -> assistant thinking (summary text, no signature)
|
|
11
|
+
commandExecution -> tool_use "shell" + tool_result
|
|
12
|
+
fileChange -> tool_use "apply_patch" + tool_result
|
|
13
|
+
mcpToolCall -> tool_use "mcp__<server>__<tool>" + tool_result
|
|
14
|
+
webSearch -> tool_use "web_search" + tool_result
|
|
15
|
+
|
|
16
|
+
A tool call is written when it *starts*, so the transcript shows it running
|
|
17
|
+
(and an approval card has a row to belong to); its result is written when it
|
|
18
|
+
completes. Codex's item id is the tool_use id on both, which is also the id
|
|
19
|
+
the approval request carries.
|
|
20
|
+
|
|
21
|
+
Egress is not implemented: a Codex session's history lives in its own
|
|
22
|
+
thread, resumed by id. Replaying canonical history into Codex is only needed
|
|
23
|
+
for cross-provider forks, which do not exist yet.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from typing import Any, Sequence
|
|
29
|
+
|
|
30
|
+
from slopper_env.adapters.base import AgentAdapter, TurnEnd
|
|
31
|
+
from slopper_env.ids import new_id
|
|
32
|
+
from slopper_env.models import BlockType, ItemType, Role
|
|
33
|
+
|
|
34
|
+
#: Canonical tool names for Codex's built-in actions.
|
|
35
|
+
SHELL = "shell"
|
|
36
|
+
APPLY_PATCH = "apply_patch"
|
|
37
|
+
WEB_SEARCH = "web_search"
|
|
38
|
+
|
|
39
|
+
#: Item statuses that mean the action did not happen as asked.
|
|
40
|
+
_FAILED = {"failed", "declined"}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CodexAdapter(AgentAdapter):
|
|
44
|
+
kind = "codex"
|
|
45
|
+
|
|
46
|
+
# ------------------------------------------------------------- ingest
|
|
47
|
+
|
|
48
|
+
def from_provider(self, message: Any) -> list[dict]:
|
|
49
|
+
method = message.get("method")
|
|
50
|
+
params = message.get("params") or {}
|
|
51
|
+
if method == "item/started":
|
|
52
|
+
call = tool_call(params.get("item") or {})
|
|
53
|
+
return [_assistant([call])] if call else []
|
|
54
|
+
if method == "item/completed":
|
|
55
|
+
return self._completed(params.get("item") or {})
|
|
56
|
+
if method == "turn/completed":
|
|
57
|
+
return self._failure(params.get("turn") or {})
|
|
58
|
+
return []
|
|
59
|
+
|
|
60
|
+
def turn_end(self, message: Any) -> TurnEnd | None:
|
|
61
|
+
if message.get("method") != "turn/completed":
|
|
62
|
+
return None
|
|
63
|
+
return TurnEnd(session_id=(message.get("params") or {}).get("threadId"))
|
|
64
|
+
|
|
65
|
+
def _completed(self, item: dict) -> list[dict]:
|
|
66
|
+
kind = item.get("type")
|
|
67
|
+
if kind == "agentMessage":
|
|
68
|
+
text = item.get("text") or ""
|
|
69
|
+
return [_assistant([{"type": BlockType.TEXT, "text": text}])] if text else []
|
|
70
|
+
if kind == "reasoning":
|
|
71
|
+
text = "\n\n".join(_strings(item.get("summary")))
|
|
72
|
+
if not text:
|
|
73
|
+
return []
|
|
74
|
+
# No signature: that is an Anthropic concept. The block is for the
|
|
75
|
+
# reader; Codex keeps its own reasoning in its thread.
|
|
76
|
+
return [_assistant([{"type": BlockType.THINKING, "text": text, "signature": ""}])]
|
|
77
|
+
result = tool_result(item)
|
|
78
|
+
if result is None:
|
|
79
|
+
return []
|
|
80
|
+
return [{"id": new_id(), "type": ItemType.MESSAGE, "role": Role.TOOL, "content": [result]}]
|
|
81
|
+
|
|
82
|
+
def _failure(self, turn: dict) -> list[dict]:
|
|
83
|
+
if turn.get("status") != "failed":
|
|
84
|
+
return []
|
|
85
|
+
error = turn.get("error") or {}
|
|
86
|
+
message = error.get("message") if isinstance(error, dict) else str(error)
|
|
87
|
+
return [
|
|
88
|
+
{
|
|
89
|
+
"type": ItemType.ERROR,
|
|
90
|
+
"source": "agent",
|
|
91
|
+
"code": "turn_failed",
|
|
92
|
+
"message": (message or "Codex reported the turn as failed.")[:2000],
|
|
93
|
+
}
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
# ------------------------------------------------------------- egress
|
|
97
|
+
|
|
98
|
+
def to_provider(self, items: Sequence[dict]) -> Any:
|
|
99
|
+
raise NotImplementedError("Codex resumes its own thread; see module docstring")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ------------------------------------------------------------------ mapping
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def tool_call(item: dict) -> dict | None:
|
|
106
|
+
"""The canonical tool_use block for a Codex item, or None if it is not one."""
|
|
107
|
+
kind = item.get("type")
|
|
108
|
+
item_id = item.get("id")
|
|
109
|
+
if not item_id:
|
|
110
|
+
return None
|
|
111
|
+
if kind == "commandExecution":
|
|
112
|
+
name, tool_input = SHELL, {"command": command_text(item)}
|
|
113
|
+
elif kind == "fileChange":
|
|
114
|
+
changes = [
|
|
115
|
+
{"path": c.get("path"), "kind": _kind(c.get("kind"))}
|
|
116
|
+
for c in item.get("changes") or []
|
|
117
|
+
]
|
|
118
|
+
tool_input = {"changes": changes}
|
|
119
|
+
if changes:
|
|
120
|
+
# Named so the transcript labels the row by its file.
|
|
121
|
+
tool_input["path"] = changes[0]["path"]
|
|
122
|
+
name = APPLY_PATCH
|
|
123
|
+
elif kind == "mcpToolCall":
|
|
124
|
+
name = f"mcp__{item.get('server')}__{item.get('tool')}"
|
|
125
|
+
arguments = item.get("arguments")
|
|
126
|
+
tool_input = arguments if isinstance(arguments, dict) else {"arguments": arguments}
|
|
127
|
+
elif kind == "webSearch":
|
|
128
|
+
name, tool_input = WEB_SEARCH, {"query": item.get("query") or ""}
|
|
129
|
+
else:
|
|
130
|
+
return None
|
|
131
|
+
return {"type": BlockType.TOOL_USE, "id": item_id, "name": name, "input": tool_input}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def tool_result(item: dict) -> dict | None:
|
|
135
|
+
"""The canonical tool_result block for a finished Codex item."""
|
|
136
|
+
kind = item.get("type")
|
|
137
|
+
item_id = item.get("id")
|
|
138
|
+
if not item_id or kind not in ("commandExecution", "fileChange", "mcpToolCall", "webSearch"):
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
failed = item.get("status") in _FAILED
|
|
142
|
+
if kind == "commandExecution":
|
|
143
|
+
exit_code = item.get("exitCode")
|
|
144
|
+
content = item.get("aggregatedOutput") or ""
|
|
145
|
+
if exit_code not in (None, 0):
|
|
146
|
+
failed = True
|
|
147
|
+
content = f"{content}\n(exit code {exit_code})".strip()
|
|
148
|
+
if item.get("status") == "declined":
|
|
149
|
+
content = content or "The user declined this."
|
|
150
|
+
elif kind == "fileChange":
|
|
151
|
+
content = "\n".join(
|
|
152
|
+
f"{_kind(c.get('kind'))} {c.get('path')}\n{c.get('diff') or ''}".rstrip()
|
|
153
|
+
for c in item.get("changes") or []
|
|
154
|
+
)
|
|
155
|
+
if item.get("status") == "declined":
|
|
156
|
+
content = "The user declined this."
|
|
157
|
+
elif kind == "mcpToolCall":
|
|
158
|
+
error = item.get("error")
|
|
159
|
+
if error:
|
|
160
|
+
failed = True
|
|
161
|
+
content = error.get("message") if isinstance(error, dict) else str(error)
|
|
162
|
+
else:
|
|
163
|
+
content = _mcp_text(item.get("result"))
|
|
164
|
+
else:
|
|
165
|
+
content = ""
|
|
166
|
+
return {
|
|
167
|
+
"type": BlockType.TOOL_RESULT,
|
|
168
|
+
"tool_use_id": item_id,
|
|
169
|
+
"content": content,
|
|
170
|
+
"is_error": failed,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def command_text(item: dict) -> str:
|
|
175
|
+
"""The command as the agent wrote it, not as Codex wrapped it.
|
|
176
|
+
|
|
177
|
+
Codex runs ``printf hi > f`` as ``/bin/zsh -lc 'printf hi > f'``; its
|
|
178
|
+
parsed ``commandActions`` keep the original, which is what a reader
|
|
179
|
+
recognises and what an approval card should show.
|
|
180
|
+
"""
|
|
181
|
+
actions = item.get("commandActions") or []
|
|
182
|
+
if len(actions) == 1 and actions[0].get("command"):
|
|
183
|
+
return str(actions[0]["command"])
|
|
184
|
+
return str(item.get("command") or "")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _assistant(content: list[dict]) -> dict:
|
|
188
|
+
return {"id": new_id(), "type": ItemType.MESSAGE, "role": Role.ASSISTANT, "content": content}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _kind(kind: Any) -> str:
|
|
192
|
+
"""A file change's kind: a plain string, or ``{"type": "update", ...}``."""
|
|
193
|
+
if isinstance(kind, dict):
|
|
194
|
+
return str(kind.get("type") or "update")
|
|
195
|
+
return str(kind or "update")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _strings(value: Any) -> list[str]:
|
|
199
|
+
if isinstance(value, str):
|
|
200
|
+
return [value] if value else []
|
|
201
|
+
if isinstance(value, list):
|
|
202
|
+
out = []
|
|
203
|
+
for part in value:
|
|
204
|
+
text = part.get("text") if isinstance(part, dict) else part
|
|
205
|
+
if isinstance(text, str) and text:
|
|
206
|
+
out.append(text)
|
|
207
|
+
return out
|
|
208
|
+
return []
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _mcp_text(result: Any) -> str:
|
|
212
|
+
"""MCP results carry content blocks; keep their text."""
|
|
213
|
+
if not isinstance(result, dict):
|
|
214
|
+
return "" if result is None else str(result)
|
|
215
|
+
texts = [
|
|
216
|
+
block.get("text", "")
|
|
217
|
+
for block in result.get("content") or []
|
|
218
|
+
if isinstance(block, dict) and block.get("type") == "text"
|
|
219
|
+
]
|
|
220
|
+
return "\n".join(texts) if texts else str(result.get("structuredContent") or "")
|