optio-codex 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.
- optio_codex/__init__.py +66 -0
- optio_codex/conversation.py +553 -0
- optio_codex/conversation_listener.py +322 -0
- optio_codex/cred_watcher.py +138 -0
- optio_codex/fs_allowlist.py +149 -0
- optio_codex/host_actions.py +1070 -0
- optio_codex/models.py +68 -0
- optio_codex/prompt.py +184 -0
- optio_codex/seed_manifest.py +91 -0
- optio_codex/session.py +731 -0
- optio_codex/snapshots.py +147 -0
- optio_codex/types.py +325 -0
- optio_codex/verify.py +352 -0
- optio_codex-0.1.0.dist-info/METADATA +220 -0
- optio_codex-0.1.0.dist-info/RECORD +17 -0
- optio_codex-0.1.0.dist-info/WHEEL +5 -0
- optio_codex-0.1.0.dist-info/top_level.txt +1 -0
optio_codex/__init__.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""optio-codex — run OpenAI Codex as an optio task."""
|
|
2
|
+
|
|
3
|
+
import logging as _logging
|
|
4
|
+
|
|
5
|
+
from optio_agents import HookContext, HookContextProtocol
|
|
6
|
+
from optio_host import (
|
|
7
|
+
HostCommandError,
|
|
8
|
+
RunResult,
|
|
9
|
+
SSHConfig,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
from optio_codex.seed_manifest import (
|
|
13
|
+
CODEX_CRED_MANIFEST,
|
|
14
|
+
CODEX_SEED_MANIFEST,
|
|
15
|
+
CODEX_SEED_SUFFIX,
|
|
16
|
+
delete_seed,
|
|
17
|
+
list_seeds,
|
|
18
|
+
purge_seed,
|
|
19
|
+
)
|
|
20
|
+
from optio_codex.session import create_codex_task, run_codex_session
|
|
21
|
+
from optio_codex.types import (
|
|
22
|
+
AllowedDir,
|
|
23
|
+
ApprovalPolicy,
|
|
24
|
+
CodexTaskConfig,
|
|
25
|
+
ConversationMode,
|
|
26
|
+
DeliverableCallback,
|
|
27
|
+
HookCallback,
|
|
28
|
+
SandboxMode,
|
|
29
|
+
ToolVerbosity,
|
|
30
|
+
ThinkingVerbosity,
|
|
31
|
+
SeedProvider,
|
|
32
|
+
SeedUnavailableError,
|
|
33
|
+
)
|
|
34
|
+
from optio_codex.verify import verify_and_refresh_seed
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_logging.getLogger("asyncssh").setLevel(_logging.WARNING)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"create_codex_task",
|
|
42
|
+
"run_codex_session",
|
|
43
|
+
"AllowedDir",
|
|
44
|
+
"ApprovalPolicy",
|
|
45
|
+
"CodexTaskConfig",
|
|
46
|
+
"ConversationMode",
|
|
47
|
+
"DeliverableCallback",
|
|
48
|
+
"HookCallback",
|
|
49
|
+
"SandboxMode",
|
|
50
|
+
"ToolVerbosity",
|
|
51
|
+
"ThinkingVerbosity",
|
|
52
|
+
"SeedProvider",
|
|
53
|
+
"SeedUnavailableError",
|
|
54
|
+
"SSHConfig",
|
|
55
|
+
"HookContext",
|
|
56
|
+
"HookContextProtocol",
|
|
57
|
+
"HostCommandError",
|
|
58
|
+
"RunResult",
|
|
59
|
+
"CODEX_SEED_MANIFEST",
|
|
60
|
+
"CODEX_CRED_MANIFEST",
|
|
61
|
+
"CODEX_SEED_SUFFIX",
|
|
62
|
+
"delete_seed",
|
|
63
|
+
"list_seeds",
|
|
64
|
+
"purge_seed",
|
|
65
|
+
"verify_and_refresh_seed",
|
|
66
|
+
]
|
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
"""CodexConversation — engine-side driver for one headless Codex session over
|
|
2
|
+
the app-server protocol: JSON-RPC 2.0 over the stdin/stdout of
|
|
3
|
+
``codex app-server``.
|
|
4
|
+
|
|
5
|
+
The session body launches ``codex app-server`` via
|
|
6
|
+
``host.launch_subprocess(stdin=True, merge_stderr=False)``, attaches the
|
|
7
|
+
handle here, starts ``run_reader()``, runs ``bootstrap()`` (the handshake),
|
|
8
|
+
publishes this object via ``ctx.publish_result``, and waits until the
|
|
9
|
+
subprocess ends.
|
|
10
|
+
|
|
11
|
+
Event payloads are transparent: every parsed stdout JSON-RPC object is fanned
|
|
12
|
+
out to ``on_event`` subscribers as a dict, unmodified. Synthetic events use
|
|
13
|
+
the ``x-optio-`` type prefix. Structurally mirrors optio-grok's
|
|
14
|
+
GrokConversation, but frames the codex app-server protocol instead of ACP.
|
|
15
|
+
|
|
16
|
+
============================================================================
|
|
17
|
+
APP-SERVER WIRE FACTS — pinned by a LIVE PROBE of the real ``codex
|
|
18
|
+
app-server`` (codex-cli 0.142.5) plus the schema dump generated by that exact
|
|
19
|
+
binary (``codex app-server generate-json-schema``). Evidence: the scratchpad
|
|
20
|
+
``probe.py`` transcript + ``schema/{ClientRequest,ServerRequest,
|
|
21
|
+
ServerNotification,ClientNotification}.json``. See the design doc
|
|
22
|
+
"Conversation transport (Stage 6)".
|
|
23
|
+
============================================================================
|
|
24
|
+
|
|
25
|
+
FRAMING: newline-delimited JSON (JSONL); JSON-RPC 2.0 semantics with the
|
|
26
|
+
``"jsonrpc"`` field OMITTED on the wire (both directions; probed). NO
|
|
27
|
+
Content-Length headers. Backpressure: error ``-32001`` "Server overloaded;
|
|
28
|
+
retry later." is retryable.
|
|
29
|
+
|
|
30
|
+
Client -> server REQUESTS (have ``id``, expect a ``result``):
|
|
31
|
+
* ``initialize`` {clientInfo:{name,title,version}, capabilities?} ->
|
|
32
|
+
{userAgent, codexHome, platformFamily, platformOs}. We do NOT set
|
|
33
|
+
``capabilities.experimentalApi`` (stable surface only);
|
|
34
|
+
``optOutNotificationMethods`` suppresses exact-match notifications
|
|
35
|
+
(we opt out of the unrendered ``item/commandExecution/outputDelta``).
|
|
36
|
+
Followed by the ``initialized`` NOTIFICATION (no id). Any other request
|
|
37
|
+
first -> "Not initialized" error.
|
|
38
|
+
* ``account/read`` {refreshToken:false} -> {account|null,
|
|
39
|
+
requiresOpenaiAuth} — bootstrap auth sanity (warn-only, never fatal:
|
|
40
|
+
fakes and API-key setups may report no account).
|
|
41
|
+
* ``model/list`` {} -> {data:[{id, displayName, hidden, isDefault, …}],
|
|
42
|
+
nextCursor} — captured raw for the widget picker (models.py maps it).
|
|
43
|
+
* ``thread/start`` {cwd, sandbox, approvalPolicy, model?} ->
|
|
44
|
+
{thread:{id,…}, model, …} (+ a ``thread/started`` notification).
|
|
45
|
+
SCHEMA CATCH: on thread/start the field is ``sandbox`` with the
|
|
46
|
+
kebab-case enum "read-only"|"workspace-write"|"danger-full-access";
|
|
47
|
+
the camelCase ``sandboxPolicy`` OBJECT exists only on turn/start
|
|
48
|
+
(0.142.5 ThreadStartParams; the probe used sandbox:"read-only").
|
|
49
|
+
NOT ephemeral — the rollout file is the resume source (Plan B).
|
|
50
|
+
* ``thread/resume`` {threadId, …same overrides} — resume path; response
|
|
51
|
+
shape matches thread/start.
|
|
52
|
+
* ``turn/start`` {threadId, input:[{type:"text",text}], model?} ->
|
|
53
|
+
IMMEDIATE ACK {turn:{id, status:"inProgress", items:[]}} — the ACK is
|
|
54
|
+
NOT the turn end. Per-turn overrides (``model``) become the default for
|
|
55
|
+
subsequent turns on the thread == the INLINE model-switch seam
|
|
56
|
+
(request_model_change pins the next turn's model; no wire write).
|
|
57
|
+
* ``turn/interrupt`` {threadId, turnId} -> {} ACK; the completion signal
|
|
58
|
+
is the subsequent ``turn/completed`` with status "interrupted".
|
|
59
|
+
|
|
60
|
+
Server -> client NOTIFICATIONS (no ``id``):
|
|
61
|
+
* ``turn/started`` {threadId, turn:{id,…}} — tracks current_turn_id.
|
|
62
|
+
* ``item/agentMessage/delta`` {threadId, turnId, itemId, delta} —
|
|
63
|
+
concatenated per itemId -> the turn's answer text.
|
|
64
|
+
* ``item/completed`` {…, item:{type,…}} — for item.type "agentMessage"
|
|
65
|
+
the item's ``text`` is authoritative for that itemId.
|
|
66
|
+
* ``item/started`` / ``item/reasoning/summaryTextDelta`` /
|
|
67
|
+
``item/reasoning/textDelta`` / ``thread/tokenUsage/updated`` /
|
|
68
|
+
``error`` {error:{message, codexErrorInfo?}, willRetry} / … — passed
|
|
69
|
+
through untouched to on_event (the UI reducer renders them).
|
|
70
|
+
* ``turn/completed`` {threadId, turn:{id, status, error?}} — THE TURN-END
|
|
71
|
+
SIGNAL, status ∈ "completed" | "interrupted" | "failed".
|
|
72
|
+
|
|
73
|
+
Server -> client REQUESTS (have ``id`` AND ``method``, WE must respond):
|
|
74
|
+
* ``item/commandExecution/requestApproval`` {threadId, turnId, itemId,
|
|
75
|
+
command?, cwd?, reason?, …} and ``item/fileChange/requestApproval``
|
|
76
|
+
{threadId, turnId, itemId, reason?, grantRoot?} — the permission gate
|
|
77
|
+
seam. ANSWER with result {"decision": D}: optio allow -> "accept";
|
|
78
|
+
optio deny -> "decline" (the agent continues the turn — "cancel" would
|
|
79
|
+
also interrupt it, which is NOT what PermissionDecision deny means).
|
|
80
|
+
The full decision enums also carry "acceptForSession" and (commands
|
|
81
|
+
only) execpolicy/network amendment objects — unused here. A deny
|
|
82
|
+
*message* is not transmittable on this wire.
|
|
83
|
+
* Anything else (``item/tool/requestUserInput``, ``item/permissions/
|
|
84
|
+
requestApproval``, ``mcpServer/elicitation/request``, ``account/
|
|
85
|
+
chatgptAuthTokens/refresh``, ``attestation/generate``, legacy
|
|
86
|
+
``applyPatchApproval``/``execCommandApproval``, ``item/tool/call``) —
|
|
87
|
+
answered with a JSON-RPC ``-32601`` error defensively (they only fire
|
|
88
|
+
for capabilities we don't advertise / flows we don't start).
|
|
89
|
+
============================================================================
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
from __future__ import annotations
|
|
93
|
+
|
|
94
|
+
import asyncio
|
|
95
|
+
import json
|
|
96
|
+
import logging
|
|
97
|
+
|
|
98
|
+
from optio_agents.conversation import (
|
|
99
|
+
ConversationClosed,
|
|
100
|
+
PermissionDecision,
|
|
101
|
+
PermissionRequest,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
_LOG = logging.getLogger(__name__)
|
|
105
|
+
|
|
106
|
+
# Server->client requests that ARE the permission gate; everything else with
|
|
107
|
+
# an id+method gets a defensive -32601.
|
|
108
|
+
_APPROVAL_METHODS = (
|
|
109
|
+
"item/commandExecution/requestApproval",
|
|
110
|
+
"item/fileChange/requestApproval",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class CodexConversation:
|
|
115
|
+
"""Implements optio_agents.conversation.Conversation for Codex
|
|
116
|
+
(app-server)."""
|
|
117
|
+
|
|
118
|
+
def __init__(
|
|
119
|
+
self,
|
|
120
|
+
*,
|
|
121
|
+
cwd: str,
|
|
122
|
+
agent_label: str = "codex",
|
|
123
|
+
permission_gate: bool = False,
|
|
124
|
+
model: str | None = None,
|
|
125
|
+
sandbox: str = "workspace-write",
|
|
126
|
+
resume_thread_id: str | None = None,
|
|
127
|
+
) -> None:
|
|
128
|
+
self._cwd = cwd
|
|
129
|
+
self._agent_label = agent_label
|
|
130
|
+
# When False, requestApproval is answered with a defensive deny
|
|
131
|
+
# instead of being queued for a handler.
|
|
132
|
+
self._permission_gate = permission_gate
|
|
133
|
+
self._model = model
|
|
134
|
+
self._sandbox = sandbox
|
|
135
|
+
# With the gate on codex must ask (on-request); without it, never.
|
|
136
|
+
self._approval_policy = "on-request" if permission_gate else "never"
|
|
137
|
+
self._resume_thread_id = resume_thread_id
|
|
138
|
+
self._handle = None
|
|
139
|
+
# Captured at bootstrap; Plan B's snapshot sessionId seam reads
|
|
140
|
+
# thread_id at capture time.
|
|
141
|
+
self.thread_id: str | None = None
|
|
142
|
+
self.current_turn_id: str | None = None
|
|
143
|
+
self.server_info: dict | None = None
|
|
144
|
+
self.account: dict | None = None
|
|
145
|
+
# Raw model/list result from bootstrap (models.parse_model_list maps
|
|
146
|
+
# it) so the session can populate the picker without a second probe.
|
|
147
|
+
self.model_list: dict | None = None
|
|
148
|
+
self.current_model_id: str | None = None
|
|
149
|
+
self._requested_model: str | None = None
|
|
150
|
+
self._pending = 0 # user turns awaiting turn/completed
|
|
151
|
+
self._closed = asyncio.Event()
|
|
152
|
+
self._close_reason: str | None = None
|
|
153
|
+
# Cooperative-shutdown request towards the owning task body.
|
|
154
|
+
self.close_requested = asyncio.Event()
|
|
155
|
+
self._write_lock = asyncio.Lock()
|
|
156
|
+
self._event_queue: asyncio.Queue[dict] = asyncio.Queue()
|
|
157
|
+
self._event_handlers: list = []
|
|
158
|
+
self._message_handlers: list = []
|
|
159
|
+
self._permission_handler = None
|
|
160
|
+
self._queued_permission_requests: list[dict] = []
|
|
161
|
+
# JSON-RPC id bookkeeping.
|
|
162
|
+
self._next_id = 0
|
|
163
|
+
self._req_futures: dict[int, asyncio.Future] = {} # handshake/interrupt
|
|
164
|
+
self._turn_req_ids: set[int] = set() # turn/start ACKs
|
|
165
|
+
# Accumulates agentMessage text for the current turn, per itemId.
|
|
166
|
+
self._answer_order: list[str] = []
|
|
167
|
+
self._answer_texts: dict[str, str] = {}
|
|
168
|
+
self._dispatcher_task: asyncio.Task | None = None
|
|
169
|
+
|
|
170
|
+
# -- wiring ------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
def attach(self, handle) -> None:
|
|
173
|
+
"""Attach the live ProcessHandle (must have been launched with
|
|
174
|
+
stdin=True)."""
|
|
175
|
+
if handle.stdin is None:
|
|
176
|
+
raise ValueError(
|
|
177
|
+
"CodexConversation.attach: handle has no stdin writer; launch "
|
|
178
|
+
"the subprocess with stdin=True"
|
|
179
|
+
)
|
|
180
|
+
self._handle = handle
|
|
181
|
+
|
|
182
|
+
async def bootstrap(self) -> None:
|
|
183
|
+
"""Run the app-server handshake: ``initialize`` + ``initialized``,
|
|
184
|
+
then ``account/read`` (auth sanity, warn-only), ``model/list`` (the
|
|
185
|
+
picker source), and ``thread/start`` / ``thread/resume``.
|
|
186
|
+
|
|
187
|
+
Requires ``run_reader()`` to already be running (it routes the
|
|
188
|
+
responses back to the futures created here).
|
|
189
|
+
"""
|
|
190
|
+
resp = await self._request("initialize", {
|
|
191
|
+
"clientInfo": {
|
|
192
|
+
"name": "optio_codex", "title": "Optio", "version": "0.1.0",
|
|
193
|
+
},
|
|
194
|
+
# Stable surface only (no experimentalApi). Opt out of the one
|
|
195
|
+
# high-volume stream nothing downstream renders.
|
|
196
|
+
"capabilities": {
|
|
197
|
+
"optOutNotificationMethods": [
|
|
198
|
+
"item/commandExecution/outputDelta",
|
|
199
|
+
],
|
|
200
|
+
},
|
|
201
|
+
})
|
|
202
|
+
self.server_info = (resp or {}).get("result") or {}
|
|
203
|
+
await self._write_json({"method": "initialized"})
|
|
204
|
+
|
|
205
|
+
resp = await self._request("account/read", {"refreshToken": False})
|
|
206
|
+
self.account = ((resp or {}).get("result") or {}).get("account")
|
|
207
|
+
if self.account is None:
|
|
208
|
+
_LOG.warning(
|
|
209
|
+
"codex conversation: account/read reports no active auth; "
|
|
210
|
+
"turns may fail until a seed/login provides credentials",
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
resp = await self._request("model/list", {})
|
|
214
|
+
self.model_list = (
|
|
215
|
+
(resp or {}).get("result") if "error" not in (resp or {}) else None
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
if self._resume_thread_id is not None:
|
|
219
|
+
method = "thread/resume"
|
|
220
|
+
params: dict = {"threadId": self._resume_thread_id}
|
|
221
|
+
else:
|
|
222
|
+
method = "thread/start"
|
|
223
|
+
params = {"cwd": self._cwd}
|
|
224
|
+
# thread/start takes `sandbox` (kebab-case enum) — NOT the turn-level
|
|
225
|
+
# `sandboxPolicy` object (see the module docstring's schema catch).
|
|
226
|
+
params["sandbox"] = self._sandbox
|
|
227
|
+
params["approvalPolicy"] = self._approval_policy
|
|
228
|
+
if self._model:
|
|
229
|
+
params["model"] = self._model
|
|
230
|
+
resp = await self._request(method, params)
|
|
231
|
+
if "error" in (resp or {}):
|
|
232
|
+
raise RuntimeError(
|
|
233
|
+
f"codex {method} failed: {(resp or {}).get('error')!r}"
|
|
234
|
+
)
|
|
235
|
+
result = (resp or {}).get("result") or {}
|
|
236
|
+
thread = result.get("thread") or {}
|
|
237
|
+
self.thread_id = thread.get("id")
|
|
238
|
+
if not self.thread_id:
|
|
239
|
+
raise RuntimeError(
|
|
240
|
+
f"codex {method} returned no thread id: {result!r}"
|
|
241
|
+
)
|
|
242
|
+
self.current_model_id = result.get("model") or self._model
|
|
243
|
+
|
|
244
|
+
async def run_reader(self) -> None:
|
|
245
|
+
"""Drain stdout until EOF; route JSON-RPC messages. Owned by the
|
|
246
|
+
session body; ends when the subprocess ends."""
|
|
247
|
+
self._dispatcher_task = asyncio.create_task(self._dispatch_loop())
|
|
248
|
+
try:
|
|
249
|
+
async for raw in self._handle.stdout:
|
|
250
|
+
line = (
|
|
251
|
+
raw.decode("utf-8", errors="replace")
|
|
252
|
+
if isinstance(raw, bytes) else str(raw)
|
|
253
|
+
).strip()
|
|
254
|
+
if not line:
|
|
255
|
+
continue
|
|
256
|
+
try:
|
|
257
|
+
obj = json.loads(line)
|
|
258
|
+
except ValueError:
|
|
259
|
+
_LOG.warning("codex conversation: unparseable line: %.200s", line)
|
|
260
|
+
self._event_queue.put_nowait(
|
|
261
|
+
{"type": "x-optio-unparseable", "line": line},
|
|
262
|
+
)
|
|
263
|
+
continue
|
|
264
|
+
self._route(obj)
|
|
265
|
+
finally:
|
|
266
|
+
await self._finish("process ended")
|
|
267
|
+
|
|
268
|
+
def _route(self, obj: dict) -> None:
|
|
269
|
+
rid = obj.get("id")
|
|
270
|
+
method = obj.get("method")
|
|
271
|
+
if method is None and rid is not None and ("result" in obj or "error" in obj):
|
|
272
|
+
# Response to one of OUR requests.
|
|
273
|
+
if rid in self._req_futures:
|
|
274
|
+
fut = self._req_futures.pop(rid)
|
|
275
|
+
if not fut.done():
|
|
276
|
+
fut.set_result(obj)
|
|
277
|
+
elif rid in self._turn_req_ids:
|
|
278
|
+
# turn/start ACK — NOT the turn end (that is turn/completed).
|
|
279
|
+
self._turn_req_ids.discard(rid)
|
|
280
|
+
if "error" in obj:
|
|
281
|
+
# The turn never started; nothing further will arrive.
|
|
282
|
+
_LOG.warning(
|
|
283
|
+
"codex conversation: turn/start rejected: %r",
|
|
284
|
+
obj.get("error"),
|
|
285
|
+
)
|
|
286
|
+
self._pending = max(0, self._pending - 1)
|
|
287
|
+
else:
|
|
288
|
+
turn = ((obj.get("result") or {}).get("turn")) or {}
|
|
289
|
+
if turn.get("id"):
|
|
290
|
+
self.current_turn_id = turn["id"]
|
|
291
|
+
elif method is not None and rid is not None:
|
|
292
|
+
# Server -> client REQUEST that we must answer.
|
|
293
|
+
if method in _APPROVAL_METHODS:
|
|
294
|
+
self._on_permission(obj)
|
|
295
|
+
else:
|
|
296
|
+
asyncio.ensure_future(self._write_json({
|
|
297
|
+
"id": rid,
|
|
298
|
+
"error": {"code": -32601,
|
|
299
|
+
"message": f"optio codex client does not implement {method}"},
|
|
300
|
+
}))
|
|
301
|
+
elif method is not None:
|
|
302
|
+
self._on_notification(method, obj)
|
|
303
|
+
self._event_queue.put_nowait(obj)
|
|
304
|
+
|
|
305
|
+
def _on_notification(self, method: str, obj: dict) -> None:
|
|
306
|
+
params = obj.get("params") or {}
|
|
307
|
+
if method == "item/agentMessage/delta":
|
|
308
|
+
item_id = str(params.get("itemId") or "")
|
|
309
|
+
delta = params.get("delta") or ""
|
|
310
|
+
if delta:
|
|
311
|
+
if item_id not in self._answer_texts:
|
|
312
|
+
self._answer_order.append(item_id)
|
|
313
|
+
self._answer_texts[item_id] = ""
|
|
314
|
+
self._answer_texts[item_id] += delta
|
|
315
|
+
elif method == "item/completed":
|
|
316
|
+
item = params.get("item") or {}
|
|
317
|
+
if item.get("type") == "agentMessage":
|
|
318
|
+
item_id = str(item.get("id") or "")
|
|
319
|
+
if item_id not in self._answer_texts:
|
|
320
|
+
self._answer_order.append(item_id)
|
|
321
|
+
# The completed item's text is authoritative for its itemId
|
|
322
|
+
# (heals any lost/duplicated deltas).
|
|
323
|
+
self._answer_texts[item_id] = (
|
|
324
|
+
item.get("text") or self._answer_texts.get(item_id, "")
|
|
325
|
+
)
|
|
326
|
+
elif method == "turn/started":
|
|
327
|
+
turn = params.get("turn") or {}
|
|
328
|
+
if turn.get("id"):
|
|
329
|
+
self.current_turn_id = turn["id"]
|
|
330
|
+
elif method == "turn/completed":
|
|
331
|
+
# THE turn-end signal (status completed | interrupted | failed).
|
|
332
|
+
self._pending = max(0, self._pending - 1)
|
|
333
|
+
self.current_turn_id = None
|
|
334
|
+
text = "".join(self._answer_texts[i] for i in self._answer_order)
|
|
335
|
+
self._answer_order = []
|
|
336
|
+
self._answer_texts = {}
|
|
337
|
+
self._fire_message(text)
|
|
338
|
+
# else: thread/started, item/started, reasoning deltas, tokenUsage,
|
|
339
|
+
# error, … — pass through to on_event only.
|
|
340
|
+
|
|
341
|
+
# -- event fan-out -----------------------------------------------------
|
|
342
|
+
|
|
343
|
+
async def _dispatch_loop(self) -> None:
|
|
344
|
+
while True:
|
|
345
|
+
obj = await self._event_queue.get()
|
|
346
|
+
for handler in list(self._event_handlers):
|
|
347
|
+
await self._call_handler(handler, obj, "on_event")
|
|
348
|
+
|
|
349
|
+
async def _call_handler(self, handler, arg, label: str) -> None:
|
|
350
|
+
try:
|
|
351
|
+
result = handler(arg)
|
|
352
|
+
if asyncio.iscoroutine(result):
|
|
353
|
+
await result
|
|
354
|
+
except Exception: # noqa: BLE001 — subscriber bugs never kill the driver
|
|
355
|
+
_LOG.exception("codex conversation: %s handler raised", label)
|
|
356
|
+
|
|
357
|
+
def _fire_message(self, text: str) -> None:
|
|
358
|
+
for handler in list(self._message_handlers):
|
|
359
|
+
asyncio.ensure_future(self._call_handler(handler, text, "on_message"))
|
|
360
|
+
|
|
361
|
+
# -- permission gate ----------------------------------------------------
|
|
362
|
+
|
|
363
|
+
def _on_permission(self, obj: dict) -> None:
|
|
364
|
+
if not self._permission_gate:
|
|
365
|
+
_LOG.warning(
|
|
366
|
+
"codex conversation: %s received with permission_gate off; "
|
|
367
|
+
"denying defensively", obj.get("method"),
|
|
368
|
+
)
|
|
369
|
+
asyncio.ensure_future(self._answer_permission_decision(
|
|
370
|
+
obj, PermissionDecision(
|
|
371
|
+
behavior="deny",
|
|
372
|
+
message="optio harness: permission gate not enabled",
|
|
373
|
+
),
|
|
374
|
+
))
|
|
375
|
+
return
|
|
376
|
+
if self._permission_handler is None:
|
|
377
|
+
# Queue until a handler is registered; the turn blocks
|
|
378
|
+
# server-side, which closes the publish/registration race.
|
|
379
|
+
self._queued_permission_requests.append(obj)
|
|
380
|
+
return
|
|
381
|
+
asyncio.ensure_future(self._answer_permission(obj))
|
|
382
|
+
|
|
383
|
+
async def _answer_permission(self, obj: dict) -> None:
|
|
384
|
+
params = obj.get("params") or {}
|
|
385
|
+
if obj.get("method") == "item/commandExecution/requestApproval":
|
|
386
|
+
tool_name = str(params.get("command") or "command execution")
|
|
387
|
+
else:
|
|
388
|
+
tool_name = "file change"
|
|
389
|
+
request = PermissionRequest(
|
|
390
|
+
tool_name=tool_name,
|
|
391
|
+
input=params,
|
|
392
|
+
raw=obj,
|
|
393
|
+
)
|
|
394
|
+
try:
|
|
395
|
+
decision = await self._permission_handler(request)
|
|
396
|
+
except Exception: # noqa: BLE001
|
|
397
|
+
_LOG.exception("codex conversation: permission handler raised; denying")
|
|
398
|
+
decision = PermissionDecision(
|
|
399
|
+
behavior="deny",
|
|
400
|
+
message="optio harness: permission handler failed",
|
|
401
|
+
)
|
|
402
|
+
await self._answer_permission_decision(obj, decision)
|
|
403
|
+
|
|
404
|
+
async def _answer_permission_decision(
|
|
405
|
+
self, obj: dict, decision: PermissionDecision,
|
|
406
|
+
) -> None:
|
|
407
|
+
# allow -> accept; deny -> decline (the agent continues the turn —
|
|
408
|
+
# "cancel" would also interrupt it, which deny does not mean). The
|
|
409
|
+
# deny message is not transmittable on this wire.
|
|
410
|
+
decision_str = "accept" if decision.behavior == "allow" else "decline"
|
|
411
|
+
await self._write_json({
|
|
412
|
+
"id": obj.get("id"),
|
|
413
|
+
"result": {"decision": decision_str},
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
# -- Conversation protocol surface --------------------------------------
|
|
417
|
+
|
|
418
|
+
async def send(self, text: str) -> None:
|
|
419
|
+
if self._closed.is_set():
|
|
420
|
+
raise ConversationClosed(self._close_reason or "conversation closed")
|
|
421
|
+
if self.thread_id is None:
|
|
422
|
+
raise RuntimeError("CodexConversation.send before bootstrap() completed")
|
|
423
|
+
self._next_id += 1
|
|
424
|
+
rid = self._next_id
|
|
425
|
+
self._turn_req_ids.add(rid)
|
|
426
|
+
self._pending += 1
|
|
427
|
+
params: dict = {
|
|
428
|
+
"threadId": self.thread_id,
|
|
429
|
+
"input": [{"type": "text", "text": text}],
|
|
430
|
+
}
|
|
431
|
+
if self._requested_model is not None:
|
|
432
|
+
# Inline model switch: the override becomes the thread default
|
|
433
|
+
# for subsequent turns (app-server contract).
|
|
434
|
+
params["model"] = self._requested_model
|
|
435
|
+
try:
|
|
436
|
+
await self._write_json({
|
|
437
|
+
"id": rid, "method": "turn/start", "params": params,
|
|
438
|
+
})
|
|
439
|
+
except Exception:
|
|
440
|
+
self._turn_req_ids.discard(rid)
|
|
441
|
+
self._pending = max(0, self._pending - 1)
|
|
442
|
+
await self._finish("stdin write failed")
|
|
443
|
+
raise
|
|
444
|
+
|
|
445
|
+
def on_event(self, handler):
|
|
446
|
+
self._event_handlers.append(handler)
|
|
447
|
+
return lambda: self._event_handlers.remove(handler)
|
|
448
|
+
|
|
449
|
+
def on_message(self, handler):
|
|
450
|
+
self._message_handlers.append(handler)
|
|
451
|
+
return lambda: self._message_handlers.remove(handler)
|
|
452
|
+
|
|
453
|
+
def on_permission_request(self, handler):
|
|
454
|
+
self._permission_handler = handler
|
|
455
|
+
queued, self._queued_permission_requests = (
|
|
456
|
+
self._queued_permission_requests, [],
|
|
457
|
+
)
|
|
458
|
+
for obj in queued:
|
|
459
|
+
asyncio.ensure_future(self._answer_permission(obj))
|
|
460
|
+
|
|
461
|
+
def _unsub() -> None:
|
|
462
|
+
if self._permission_handler is handler:
|
|
463
|
+
self._permission_handler = None
|
|
464
|
+
return _unsub
|
|
465
|
+
|
|
466
|
+
def is_pending(self) -> bool:
|
|
467
|
+
return self._pending > 0
|
|
468
|
+
|
|
469
|
+
async def interrupt(self) -> None:
|
|
470
|
+
if self._closed.is_set():
|
|
471
|
+
raise ConversationClosed(self._close_reason or "conversation closed")
|
|
472
|
+
if (
|
|
473
|
+
self._pending == 0
|
|
474
|
+
or self.thread_id is None
|
|
475
|
+
or self.current_turn_id is None
|
|
476
|
+
):
|
|
477
|
+
return
|
|
478
|
+
# turn/interrupt is a normal request; its {} ACK is NOT the
|
|
479
|
+
# completion signal — the in-flight turn ends via turn/completed
|
|
480
|
+
# with status "interrupted".
|
|
481
|
+
await self._request("turn/interrupt", {
|
|
482
|
+
"threadId": self.thread_id, "turnId": self.current_turn_id,
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
def request_model_change(self, model: str) -> None:
|
|
486
|
+
"""Switch model mid-conversation INLINE — with NO wire write: a
|
|
487
|
+
``model`` override on the next ``turn/start`` becomes the thread's
|
|
488
|
+
default for subsequent turns (app-server contract; see models.py).
|
|
489
|
+
Synchronous surface (the listener calls it without await)."""
|
|
490
|
+
if self._closed.is_set():
|
|
491
|
+
raise ConversationClosed(self._close_reason or "conversation closed")
|
|
492
|
+
if self.thread_id is None:
|
|
493
|
+
raise RuntimeError(
|
|
494
|
+
"CodexConversation.request_model_change before bootstrap() completed"
|
|
495
|
+
)
|
|
496
|
+
self._requested_model = model
|
|
497
|
+
self.current_model_id = model # optimistic; the next turn pins it
|
|
498
|
+
|
|
499
|
+
async def close(self) -> None:
|
|
500
|
+
self.close_requested.set()
|
|
501
|
+
|
|
502
|
+
@property
|
|
503
|
+
def closed(self) -> bool:
|
|
504
|
+
return self._closed.is_set()
|
|
505
|
+
|
|
506
|
+
# -- internals -----------------------------------------------------------
|
|
507
|
+
|
|
508
|
+
async def _request(self, method: str, params: dict) -> dict:
|
|
509
|
+
"""Send a client->server request and await its response (handshake +
|
|
510
|
+
turn/interrupt only; turn/start is tracked via _turn_req_ids)."""
|
|
511
|
+
self._next_id += 1
|
|
512
|
+
rid = self._next_id
|
|
513
|
+
fut: asyncio.Future = asyncio.get_event_loop().create_future()
|
|
514
|
+
self._req_futures[rid] = fut
|
|
515
|
+
await self._write_json({"id": rid, "method": method, "params": params})
|
|
516
|
+
return await fut
|
|
517
|
+
|
|
518
|
+
async def _write_json(self, obj: dict) -> None:
|
|
519
|
+
# The app-server wire omits the "jsonrpc" field (probed; README).
|
|
520
|
+
await self._write_bytes((json.dumps(obj) + "\n").encode("utf-8"))
|
|
521
|
+
|
|
522
|
+
async def _write_bytes(self, data: bytes) -> None:
|
|
523
|
+
async with self._write_lock:
|
|
524
|
+
stdin = self._handle.stdin
|
|
525
|
+
stdin.write(data)
|
|
526
|
+
drain = getattr(stdin, "drain", None)
|
|
527
|
+
if drain is not None:
|
|
528
|
+
await drain()
|
|
529
|
+
|
|
530
|
+
async def _finish(self, reason: str) -> None:
|
|
531
|
+
if self._closed.is_set():
|
|
532
|
+
return
|
|
533
|
+
self._closed.set()
|
|
534
|
+
self._close_reason = reason
|
|
535
|
+
# Fail any in-flight handshake/interrupt requests.
|
|
536
|
+
for fut in self._req_futures.values():
|
|
537
|
+
if not fut.done():
|
|
538
|
+
fut.set_exception(ConversationClosed(reason))
|
|
539
|
+
self._req_futures.clear()
|
|
540
|
+
self._event_queue.put_nowait({"type": "x-optio-closed", "reason": reason})
|
|
541
|
+
# Stop the dispatcher, then drain whatever it left so subscribers are
|
|
542
|
+
# guaranteed to see the final x-optio-closed event.
|
|
543
|
+
if self._dispatcher_task is not None:
|
|
544
|
+
self._dispatcher_task.cancel()
|
|
545
|
+
try:
|
|
546
|
+
await self._dispatcher_task
|
|
547
|
+
except asyncio.CancelledError:
|
|
548
|
+
pass
|
|
549
|
+
self._dispatcher_task = None
|
|
550
|
+
while not self._event_queue.empty():
|
|
551
|
+
obj = self._event_queue.get_nowait()
|
|
552
|
+
for handler in list(self._event_handlers):
|
|
553
|
+
await self._call_handler(handler, obj, "on_event")
|