python-codex 0.2.7__py3-none-any.whl → 0.3.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.
- pycodex/__init__.py +14 -14
- pycodex/agent.py +465 -499
- pycodex/bootstrap.py +417 -0
- pycodex/cli.py +236 -510
- pycodex/compat.py +19 -5
- pycodex/context.py +222 -212
- pycodex/doctor.py +52 -48
- pycodex/events.py +857 -0
- pycodex/feishu_card.py +217 -163
- pycodex/feishu_link.py +43 -83
- pycodex/model.py +324 -253
- pycodex/model_metadata.py +19 -7
- pycodex/portable.py +76 -45
- pycodex/portable_server.py +32 -24
- pycodex/prompts/models.json +245 -983
- pycodex/protocol.py +177 -137
- pycodex/runtime.py +579 -176
- pycodex/runtime_services.py +204 -157
- pycodex/tools/__init__.py +1 -1
- pycodex/tools/apply_patch_tool.py +69 -48
- pycodex/tools/base_tool.py +89 -42
- pycodex/tools/clock_tool.py +58 -25
- pycodex/tools/close_agent_tool.py +2 -2
- pycodex/tools/code_mode_manager.py +77 -64
- pycodex/tools/exec_command_tool.py +26 -11
- pycodex/tools/exec_tool.py +4 -4
- pycodex/tools/grep_files_tool.py +12 -10
- pycodex/tools/ipython_tool.py +10 -13
- pycodex/tools/list_dir_tool.py +13 -9
- pycodex/tools/read_file_tool.py +29 -17
- pycodex/tools/request_permissions_tool.py +15 -5
- pycodex/tools/request_user_input_tool.py +13 -104
- pycodex/tools/resume_agent_tool.py +2 -2
- pycodex/tools/send_input_tool.py +11 -8
- pycodex/tools/shell_command_tool.py +7 -5
- pycodex/tools/shell_tool.py +7 -5
- pycodex/tools/spawn_agent_tool.py +7 -4
- pycodex/tools/unified_exec_manager.py +102 -69
- pycodex/tools/update_plan_tool.py +8 -5
- pycodex/tools/view_image_tool.py +7 -5
- pycodex/tools/wait_agent_tool.py +27 -4
- pycodex/tools/wait_tool.py +5 -4
- pycodex/tools/web_search_tool.py +4 -2
- pycodex/tools/write_stdin_tool.py +12 -11
- pycodex/utils/__init__.py +2 -17
- pycodex/utils/compactor.py +41 -72
- pycodex/utils/debug.py +2 -2
- pycodex/utils/dotenv.py +6 -7
- pycodex/utils/event_helpers.py +190 -0
- pycodex/utils/get_env.py +27 -70
- pycodex/{image_utils.py → utils/image_utils.py} +8 -11
- pycodex/utils/random_ids.py +1 -2
- pycodex/utils/session_persist.py +217 -163
- pycodex/utils/truncation.py +21 -45
- python_codex-0.3.0.dist-info/METADATA +704 -0
- python_codex-0.3.0.dist-info/RECORD +90 -0
- responses_server/__init__.py +1 -5
- responses_server/__main__.py +0 -1
- responses_server/app.py +36 -31
- responses_server/config.py +23 -23
- responses_server/messages_api.py +51 -53
- responses_server/payload_processors.py +25 -20
- responses_server/server.py +11 -11
- responses_server/session_store.py +14 -11
- responses_server/stream_router.py +101 -98
- responses_server/tools/custom_adapter.py +17 -16
- responses_server/tools/web_search.py +39 -36
- responses_server/trajectory_dump.py +36 -14
- workspace_server/__main__.py +0 -1
- workspace_server/app.py +461 -375
- workspace_server/workspace.html +852 -228
- workspace_server/workspaces.html +94 -95
- workspace_server/workspaces.py +137 -79
- pycodex/collaboration.py +0 -20
- pycodex/interactive_session.py +0 -415
- pycodex/prompts/collaboration_default.md +0 -11
- pycodex/prompts/collaboration_plan.md +0 -128
- pycodex/utils/toolcall_visualize.py +0 -713
- pycodex/utils/visualize.py +0 -560
- python_codex-0.2.7.dist-info/METADATA +0 -455
- python_codex-0.2.7.dist-info/RECORD +0 -93
- {python_codex-0.2.7.dist-info → python_codex-0.3.0.dist-info}/WHEEL +0 -0
- {python_codex-0.2.7.dist-info → python_codex-0.3.0.dist-info}/entry_points.txt +0 -0
- {python_codex-0.2.7.dist-info → python_codex-0.3.0.dist-info}/licenses/LICENSE +0 -0
pycodex/runtime.py
CHANGED
|
@@ -1,225 +1,628 @@
|
|
|
1
|
-
|
|
2
1
|
import asyncio
|
|
2
|
+
import typing
|
|
3
3
|
from collections import deque
|
|
4
|
-
from dataclasses import dataclass
|
|
4
|
+
from dataclasses import dataclass, field, replace
|
|
5
5
|
|
|
6
|
-
from .agent import
|
|
6
|
+
from .agent import BASE_EVENT_HANDLER, Agent, TurnInterrupted
|
|
7
7
|
from .compat import Literal
|
|
8
|
-
from .
|
|
8
|
+
from .events import (
|
|
9
|
+
AssistantDeltaEvent,
|
|
10
|
+
AutoCompactCompletedEvent,
|
|
11
|
+
CommandCompletedEvent,
|
|
12
|
+
CommandFailedEvent,
|
|
13
|
+
Event,
|
|
14
|
+
InputQueuedEvent,
|
|
15
|
+
InputRequestedEvent,
|
|
16
|
+
InputResolvedEvent,
|
|
17
|
+
SessionClosedEvent,
|
|
18
|
+
SessionStateEvent,
|
|
19
|
+
StreamErrorEvent,
|
|
20
|
+
TerminalEvent,
|
|
21
|
+
ToolCompletedEvent,
|
|
22
|
+
TurnCompletedEvent,
|
|
23
|
+
TurnEvent,
|
|
24
|
+
TurnFailedEvent,
|
|
25
|
+
TurnInterruptedEvent,
|
|
26
|
+
TurnStartedEvent,
|
|
27
|
+
)
|
|
28
|
+
from .protocol import TurnResult
|
|
9
29
|
from .utils import uuid7_string
|
|
10
|
-
import
|
|
30
|
+
from .utils.event_helpers import shorten_title
|
|
31
|
+
from .utils.session_persist import (
|
|
32
|
+
conversation_history_to_turns,
|
|
33
|
+
list_resumable_sessions,
|
|
34
|
+
resolve_codex_home,
|
|
35
|
+
select_resumable_session,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SubmissionInterrupted(RuntimeError):
|
|
40
|
+
def __init__(self) -> "None":
|
|
41
|
+
super().__init__("submission interrupted")
|
|
11
42
|
|
|
12
43
|
|
|
13
44
|
@dataclass
|
|
14
45
|
class _QueuedSubmission:
|
|
15
|
-
|
|
16
|
-
turn_id:
|
|
17
|
-
|
|
46
|
+
submission_id: "str"
|
|
47
|
+
turn_id: "str"
|
|
48
|
+
texts: "typing.List[str]"
|
|
49
|
+
futures: "typing.List[asyncio.Future[TurnResult]]"
|
|
18
50
|
|
|
19
51
|
|
|
20
|
-
|
|
21
|
-
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class SubmittedInput:
|
|
54
|
+
submission_id: "str"
|
|
55
|
+
kind: "str"
|
|
56
|
+
future: "asyncio.Future"
|
|
22
57
|
|
|
23
|
-
def __init__(self, agent: 'Agent') -> 'None':
|
|
24
|
-
self._agent = agent
|
|
25
|
-
self._enqueue_queue: 'deque[_QueuedSubmission]' = deque()
|
|
26
|
-
self._steer_queue: 'deque[_QueuedSubmission]' = deque()
|
|
27
|
-
self._queue_lock = asyncio.Lock()
|
|
28
|
-
self._queue_event = asyncio.Event()
|
|
29
|
-
self._current_submission: 'typing.Union[_QueuedSubmission, None]' = None
|
|
30
|
-
self._current_task: 'typing.Union[asyncio.Task[TurnResult], None]' = None
|
|
31
|
-
self._event_handler = BASE_EVENT_HANDLER
|
|
32
|
-
self._agent.set_event_handler(self._handle_agent_event)
|
|
33
58
|
|
|
34
|
-
|
|
35
|
-
|
|
59
|
+
@dataclass
|
|
60
|
+
class _InputRequest:
|
|
61
|
+
request_id: "str"
|
|
62
|
+
kind: "str"
|
|
63
|
+
payload: "dict"
|
|
64
|
+
future: "asyncio.Future"
|
|
65
|
+
question_index: "int" = 0
|
|
66
|
+
answers: "dict" = field(default_factory=dict)
|
|
67
|
+
other: "bool" = False
|
|
68
|
+
timer: "typing.Union[asyncio.TimerHandle, None]" = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class AgentRuntime:
|
|
72
|
+
"""Frontend-independent session input, scheduling, state and events."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, agent: "Agent") -> "None":
|
|
75
|
+
self.agent = agent
|
|
76
|
+
self._enqueue_queue: "deque[_QueuedSubmission]" = deque()
|
|
77
|
+
self._steer_queue: "deque[_QueuedSubmission]" = deque()
|
|
78
|
+
self._queue_event = None
|
|
79
|
+
self._current_submission: "typing.Union[_QueuedSubmission, None]" = None
|
|
80
|
+
self.event_handler = BASE_EVENT_HANDLER
|
|
81
|
+
self.agent.event_handler = self._handle_agent_event
|
|
82
|
+
self.codex_home = (
|
|
83
|
+
agent.context_manager._config.codex_home or resolve_codex_home()
|
|
84
|
+
)
|
|
85
|
+
self.title = ""
|
|
86
|
+
self.command_handlers = {}
|
|
87
|
+
self._frontends = {}
|
|
88
|
+
self._close_handlers = []
|
|
89
|
+
self._worker = None
|
|
90
|
+
self._command_lock = None
|
|
91
|
+
self._input_request = None
|
|
92
|
+
self._background_work_count = 0
|
|
93
|
+
self._active_turn = None
|
|
94
|
+
self._recorded_rollout_path = agent.recorded_session_file_path
|
|
95
|
+
environment = agent.tool_registry.runtime_environment
|
|
96
|
+
environment.request_user_input_manager.set_handler(self._request_user_input)
|
|
97
|
+
environment.request_permissions_manager.set_handler(self._request_permissions)
|
|
98
|
+
|
|
99
|
+
def attach(self, event_handler):
|
|
100
|
+
frontend_id = uuid7_string()
|
|
101
|
+
self._frontends[frontend_id] = event_handler
|
|
102
|
+
self._notify_handler(
|
|
103
|
+
event_handler, SessionStateEvent("attach", self.snapshot())
|
|
104
|
+
)
|
|
105
|
+
return frontend_id
|
|
106
|
+
|
|
107
|
+
def detach(self, frontend_id):
|
|
108
|
+
del self._frontends[frontend_id]
|
|
109
|
+
if not self._frontends and self._input_request is not None:
|
|
110
|
+
self._finish_input_request(None)
|
|
111
|
+
|
|
112
|
+
def register_command(self, name, handler):
|
|
113
|
+
if name in self.commands():
|
|
114
|
+
raise ValueError("command already registered: /{0}".format(name))
|
|
115
|
+
self.command_handlers[name] = handler
|
|
116
|
+
|
|
117
|
+
def add_close_handler(self, handler):
|
|
118
|
+
self._close_handlers.append(handler)
|
|
119
|
+
|
|
120
|
+
def commands(self):
|
|
121
|
+
return (
|
|
122
|
+
(
|
|
123
|
+
"help",
|
|
124
|
+
"history",
|
|
125
|
+
"title",
|
|
126
|
+
"model",
|
|
127
|
+
"resume",
|
|
128
|
+
"compact",
|
|
129
|
+
"fork",
|
|
130
|
+
)
|
|
131
|
+
+ tuple(self.command_handlers)
|
|
132
|
+
+ ("queue", "exit", "quit")
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def snapshot(self):
|
|
136
|
+
rollout_path = self.agent.session_file_path
|
|
137
|
+
recorded_path = self.agent.recorded_session_file_path
|
|
138
|
+
return {
|
|
139
|
+
"session_id": self.agent.session_id,
|
|
140
|
+
"rollout_path": str(rollout_path) if rollout_path is not None else None,
|
|
141
|
+
"recorded_rollout_path": (
|
|
142
|
+
str(recorded_path) if recorded_path is not None else None
|
|
143
|
+
),
|
|
144
|
+
"model": self.agent.model_name,
|
|
145
|
+
"title": self.title,
|
|
146
|
+
"history": conversation_history_to_turns(self.agent.history),
|
|
147
|
+
"busy": self.is_busy,
|
|
148
|
+
"closed": self.agent.is_shutdown,
|
|
149
|
+
"accepts_input": self.agent.accepts_input,
|
|
150
|
+
"context_window": self.agent.context_manager.resolve_model_context_window(),
|
|
151
|
+
"max_context_window": (
|
|
152
|
+
self.agent.context_manager.resolve_model_max_context_window()
|
|
153
|
+
),
|
|
154
|
+
"auto_compact_token_limit": (
|
|
155
|
+
self.agent.context_manager.resolve_auto_compact_token_limit()
|
|
156
|
+
),
|
|
157
|
+
"usage_tokens": self.agent._last_total_usage_tokens,
|
|
158
|
+
"background_work_count": self._background_work_count,
|
|
159
|
+
"input_request": self._input_request_event(),
|
|
160
|
+
"active_turn": (
|
|
161
|
+
dict(self._active_turn) if self._active_turn is not None else None
|
|
162
|
+
),
|
|
163
|
+
"plan": self.agent.tool_registry.runtime_environment.plan_store.snapshot(),
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
def publish_state(self, reason):
|
|
167
|
+
self._recorded_rollout_path = self.agent.recorded_session_file_path
|
|
168
|
+
self._publish(SessionStateEvent(reason, self.snapshot()))
|
|
169
|
+
|
|
170
|
+
def require_idle(self, operation):
|
|
171
|
+
if self.is_busy:
|
|
172
|
+
raise RuntimeError(
|
|
173
|
+
"Cannot {0} while work is running or queued.".format(operation)
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def set_title(self, title):
|
|
177
|
+
self.title = title
|
|
178
|
+
self.publish_state("title")
|
|
179
|
+
|
|
180
|
+
def set_model(self, model):
|
|
181
|
+
self.require_idle("change model")
|
|
182
|
+
self.agent.set_model(model)
|
|
183
|
+
self.publish_state("model")
|
|
184
|
+
|
|
185
|
+
def resume(self, path=None, title=""):
|
|
186
|
+
self.require_idle("resume")
|
|
187
|
+
resumed = self.agent.resume(path)
|
|
188
|
+
if resumed is not None:
|
|
189
|
+
self.title = title or str(resumed["title"])
|
|
190
|
+
self.publish_state("history" if path is not None else "admission")
|
|
191
|
+
return resumed
|
|
192
|
+
|
|
193
|
+
def fork(self):
|
|
194
|
+
self.require_idle("fork")
|
|
195
|
+
self.agent.fork()
|
|
196
|
+
self.publish_state("identity")
|
|
197
|
+
|
|
198
|
+
async def start(self, config_path=None):
|
|
199
|
+
if config_path is not None:
|
|
200
|
+
self.codex_home = resolve_codex_home(config_path)
|
|
201
|
+
if self._worker is None or self._worker.done():
|
|
202
|
+
if self.agent.is_shutdown:
|
|
203
|
+
raise RuntimeError("resume the agent before restarting its runtime")
|
|
204
|
+
self.agent.accepts_input = True
|
|
205
|
+
self._worker = asyncio.create_task(self._run_forever())
|
|
206
|
+
return self
|
|
207
|
+
|
|
208
|
+
def _get_command_lock(self):
|
|
209
|
+
if self._command_lock is None:
|
|
210
|
+
self._command_lock = asyncio.Lock()
|
|
211
|
+
return self._command_lock
|
|
212
|
+
|
|
213
|
+
def _wake_worker(self):
|
|
214
|
+
if self._queue_event is not None:
|
|
215
|
+
self._queue_event.set()
|
|
216
|
+
|
|
217
|
+
async def close(self):
|
|
218
|
+
if self._worker is None:
|
|
219
|
+
self._worker = asyncio.create_task(self._run_forever())
|
|
220
|
+
if self.accepts_input:
|
|
221
|
+
self.agent.accepts_input = False
|
|
222
|
+
self.publish_state("admission")
|
|
223
|
+
if self._input_request is not None:
|
|
224
|
+
self._finish_input_request(None)
|
|
225
|
+
self._wake_worker()
|
|
226
|
+
await asyncio.shield(self._worker)
|
|
227
|
+
|
|
228
|
+
async def submit_input(self, text, sender="user"):
|
|
229
|
+
if not self.agent.accepts_input:
|
|
230
|
+
raise RuntimeError("agent is shutting down")
|
|
231
|
+
text = text.strip()
|
|
232
|
+
if self._input_request is not None and not text.startswith("/"):
|
|
233
|
+
self._answer_input(text)
|
|
234
|
+
future = asyncio.get_running_loop().create_future()
|
|
235
|
+
future.set_result(None)
|
|
236
|
+
return SubmittedInput(uuid7_string(), "answer", future)
|
|
237
|
+
if not text:
|
|
238
|
+
future = asyncio.get_running_loop().create_future()
|
|
239
|
+
future.set_result(None)
|
|
240
|
+
return SubmittedInput(uuid7_string(), "empty", future)
|
|
241
|
+
parts = text.split(None, 1)
|
|
242
|
+
command = parts[0]
|
|
243
|
+
argument = parts[1] if len(parts) == 2 else ""
|
|
244
|
+
if command == "/queue":
|
|
245
|
+
if not argument.strip():
|
|
246
|
+
raise ValueError("Usage: /queue <message>")
|
|
247
|
+
return await self._submit_message(argument.strip(), "enqueue", sender, True)
|
|
248
|
+
if not command.startswith("/"):
|
|
249
|
+
return await self._submit_message(text, "steer", sender, False)
|
|
250
|
+
submission_id = uuid7_string()
|
|
251
|
+
future = asyncio.get_running_loop().create_future()
|
|
252
|
+
try:
|
|
253
|
+
if command in {"/exit", "/quit"}:
|
|
254
|
+
result = await self._execute_command(command[1:], argument.strip())
|
|
255
|
+
else:
|
|
256
|
+
async with self._get_command_lock():
|
|
257
|
+
if not self.agent.accepts_input:
|
|
258
|
+
raise RuntimeError("agent is shutting down")
|
|
259
|
+
result = await self._execute_command(command[1:], argument.strip())
|
|
260
|
+
except Exception as exc:
|
|
261
|
+
future.set_exception(exc)
|
|
262
|
+
self._publish(CommandFailedEvent(submission_id, command, str(exc), sender))
|
|
263
|
+
else:
|
|
264
|
+
future.set_result(result)
|
|
265
|
+
self._publish(CommandCompletedEvent(submission_id, command, result, sender))
|
|
266
|
+
future.add_done_callback(self._observe_completion)
|
|
267
|
+
return SubmittedInput(submission_id, "command", future)
|
|
268
|
+
|
|
269
|
+
async def _execute_command(self, command, argument):
|
|
270
|
+
if (
|
|
271
|
+
command in {"help", "history", "compact", "fork", "exit", "quit"}
|
|
272
|
+
and argument
|
|
273
|
+
):
|
|
274
|
+
raise ValueError("Usage: /{0}".format(command))
|
|
275
|
+
if command == "help":
|
|
276
|
+
return {"kind": "help", "commands": self.commands()}
|
|
277
|
+
if command == "history":
|
|
278
|
+
return {"kind": "history", "state": self.snapshot()}
|
|
279
|
+
if command == "title":
|
|
280
|
+
if argument:
|
|
281
|
+
self.set_title(argument)
|
|
282
|
+
return {
|
|
283
|
+
"kind": "title_changed" if argument else "title",
|
|
284
|
+
"title": self.title,
|
|
285
|
+
}
|
|
286
|
+
if command == "model":
|
|
287
|
+
if argument:
|
|
288
|
+
self.set_model(argument)
|
|
289
|
+
return {"kind": "model_changed", "model": self.agent.model_name}
|
|
290
|
+
return {
|
|
291
|
+
"kind": "models",
|
|
292
|
+
"model": self.agent.model_name,
|
|
293
|
+
"models": await self.agent.model_client.list_models(),
|
|
294
|
+
}
|
|
295
|
+
if command == "resume":
|
|
296
|
+
if not argument:
|
|
297
|
+
return {
|
|
298
|
+
"kind": "sessions",
|
|
299
|
+
"sessions": list_resumable_sessions(self.codex_home),
|
|
300
|
+
}
|
|
301
|
+
self.require_idle("resume")
|
|
302
|
+
selected = select_resumable_session(self.codex_home, argument)
|
|
303
|
+
self.resume(selected["rollout_path"], selected["title"])
|
|
304
|
+
return {"kind": "resumed", "state": self.snapshot()}
|
|
305
|
+
if command == "compact":
|
|
306
|
+
self.require_idle("compact")
|
|
307
|
+
if not self.agent.history:
|
|
308
|
+
return {"kind": "compact_empty"}
|
|
309
|
+
result = await self.agent.compact()
|
|
310
|
+
self.publish_state("history")
|
|
311
|
+
return {
|
|
312
|
+
"kind": "compacted",
|
|
313
|
+
"original_item_count": result.original_item_count,
|
|
314
|
+
"retained_item_count": result.retained_item_count,
|
|
315
|
+
"pruned_tool_results": result.pruned_tool_results,
|
|
316
|
+
}
|
|
317
|
+
if command == "fork":
|
|
318
|
+
self.fork()
|
|
319
|
+
return {"kind": "forked", "session_id": self.agent.session_id}
|
|
320
|
+
if command in {"exit", "quit"}:
|
|
321
|
+
await self.close()
|
|
322
|
+
return {"kind": "closed"}
|
|
323
|
+
handler = self.command_handlers.get(command)
|
|
324
|
+
if handler is None:
|
|
325
|
+
raise ValueError("Unknown command: /{0}".format(command))
|
|
326
|
+
return await handler(argument)
|
|
327
|
+
|
|
328
|
+
async def _submit_message(self, text, queue, sender, explicit_queue):
|
|
329
|
+
busy = self.is_busy
|
|
330
|
+
submission_id, future = await self.enqueue_user_turn(text, queue)
|
|
331
|
+
future.add_done_callback(self._observe_completion)
|
|
332
|
+
self._publish(
|
|
333
|
+
InputQueuedEvent(submission_id, text, queue, sender, explicit_queue, busy)
|
|
334
|
+
)
|
|
335
|
+
return SubmittedInput(submission_id, "turn", future)
|
|
336
|
+
|
|
337
|
+
@staticmethod
|
|
338
|
+
def _observe_completion(future):
|
|
339
|
+
if not future.cancelled():
|
|
340
|
+
future.exception()
|
|
341
|
+
|
|
342
|
+
async def _request_user_input(self, payload):
|
|
343
|
+
return await self._request_input("questions", payload)
|
|
344
|
+
|
|
345
|
+
async def _request_permissions(self, payload):
|
|
346
|
+
return await self._request_input("permissions", payload)
|
|
347
|
+
|
|
348
|
+
async def _request_input(self, kind, payload):
|
|
349
|
+
if not self._frontends or not self.agent.accepts_input:
|
|
350
|
+
return None
|
|
351
|
+
if self._input_request is not None:
|
|
352
|
+
raise RuntimeError("a user input request is already pending")
|
|
353
|
+
future = asyncio.get_running_loop().create_future()
|
|
354
|
+
request = _InputRequest(uuid7_string(), kind, payload, future)
|
|
355
|
+
self._input_request = request
|
|
356
|
+
if kind == "questions" and not payload["questions"]:
|
|
357
|
+
self._finish_input_request({"answers": {}})
|
|
358
|
+
else:
|
|
359
|
+
self._publish_input_request()
|
|
360
|
+
timeout = payload.get("autoResolutionMs")
|
|
361
|
+
request.timer = (
|
|
362
|
+
None
|
|
363
|
+
if timeout is None
|
|
364
|
+
else asyncio.get_running_loop().call_later(
|
|
365
|
+
timeout / 1000.0,
|
|
366
|
+
self._finish_input_request,
|
|
367
|
+
None,
|
|
368
|
+
)
|
|
369
|
+
)
|
|
370
|
+
try:
|
|
371
|
+
return await future
|
|
372
|
+
finally:
|
|
373
|
+
if request.timer is not None:
|
|
374
|
+
request.timer.cancel()
|
|
375
|
+
if self._input_request is request:
|
|
376
|
+
self._input_request = None
|
|
377
|
+
self._publish(InputResolvedEvent(request.request_id))
|
|
378
|
+
|
|
379
|
+
def _input_request_event(self):
|
|
380
|
+
request = self._input_request
|
|
381
|
+
if request is None:
|
|
382
|
+
return None
|
|
383
|
+
if request.kind == "questions":
|
|
384
|
+
return InputRequestedEvent(
|
|
385
|
+
request.request_id,
|
|
386
|
+
request.kind,
|
|
387
|
+
request.other,
|
|
388
|
+
question=request.payload["questions"][request.question_index],
|
|
389
|
+
)
|
|
390
|
+
return InputRequestedEvent(
|
|
391
|
+
request.request_id,
|
|
392
|
+
request.kind,
|
|
393
|
+
request.other,
|
|
394
|
+
permissions=request.payload,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
def _publish_input_request(self):
|
|
398
|
+
self._publish(self._input_request_event())
|
|
399
|
+
|
|
400
|
+
def _answer_input(self, text):
|
|
401
|
+
request = self._input_request
|
|
402
|
+
if not text:
|
|
403
|
+
self._finish_input_request(None)
|
|
404
|
+
return
|
|
405
|
+
if request.kind == "permissions":
|
|
406
|
+
answer = text.lower()
|
|
407
|
+
granted = answer in {"t", "turn", "y", "yes", "s", "session"}
|
|
408
|
+
self._finish_input_request(
|
|
409
|
+
{
|
|
410
|
+
"permissions": (
|
|
411
|
+
request.payload.get("permissions", {}) if granted else {}
|
|
412
|
+
),
|
|
413
|
+
"scope": "session" if answer in {"s", "session"} else "turn",
|
|
414
|
+
}
|
|
415
|
+
)
|
|
416
|
+
return
|
|
417
|
+
question = request.payload["questions"][request.question_index]
|
|
418
|
+
options = question["options"]
|
|
419
|
+
if not request.other and text.isdigit():
|
|
420
|
+
choice = int(text)
|
|
421
|
+
if choice == 0:
|
|
422
|
+
request.other = True
|
|
423
|
+
self._publish_input_request()
|
|
424
|
+
return
|
|
425
|
+
if 1 <= choice <= len(options):
|
|
426
|
+
text = options[choice - 1]["label"]
|
|
427
|
+
request.answers[question["id"]] = {"answers": [text]}
|
|
428
|
+
request.question_index += 1
|
|
429
|
+
request.other = False
|
|
430
|
+
if request.question_index == len(request.payload["questions"]):
|
|
431
|
+
self._finish_input_request({"answers": request.answers})
|
|
432
|
+
else:
|
|
433
|
+
self._publish_input_request()
|
|
434
|
+
|
|
435
|
+
def answer_input(self, request_id, answer):
|
|
436
|
+
if self._input_request is None or self._input_request.request_id != request_id:
|
|
437
|
+
raise ValueError("input request is no longer pending")
|
|
438
|
+
self._finish_input_request(answer)
|
|
439
|
+
|
|
440
|
+
def _finish_input_request(self, answer):
|
|
441
|
+
request, self._input_request = self._input_request, None
|
|
442
|
+
if request.timer is not None:
|
|
443
|
+
request.timer.cancel()
|
|
444
|
+
request.future.set_result(answer)
|
|
445
|
+
self._publish(InputResolvedEvent(request.request_id))
|
|
36
446
|
|
|
37
|
-
|
|
447
|
+
@property
|
|
448
|
+
def accepts_input(self) -> "bool":
|
|
449
|
+
return self.agent.accepts_input
|
|
450
|
+
|
|
451
|
+
@property
|
|
452
|
+
def is_busy(self) -> "bool":
|
|
453
|
+
return (
|
|
454
|
+
self.agent.is_running
|
|
455
|
+
or self._current_submission is not None
|
|
456
|
+
or bool(self._steer_queue or self._enqueue_queue)
|
|
457
|
+
or (not self.accepts_input and not self.agent.is_shutdown)
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
async def submit_user_turn(self, text: "str") -> "TurnResult":
|
|
38
461
|
_submission_id, future = await self.enqueue_user_turn(text, queue="enqueue")
|
|
39
|
-
|
|
40
|
-
assert result is not None
|
|
41
|
-
return result
|
|
462
|
+
return await future
|
|
42
463
|
|
|
43
464
|
async def enqueue_user_turn(
|
|
44
465
|
self,
|
|
45
|
-
text:
|
|
466
|
+
text: "str",
|
|
46
467
|
queue: 'Literal["enqueue", "steer"]' = "enqueue",
|
|
47
|
-
) ->
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
468
|
+
) -> "typing.Tuple[str, asyncio.Future[TurnResult]]":
|
|
469
|
+
if queue not in {"enqueue", "steer"}:
|
|
470
|
+
raise ValueError(f"unknown submission queue: {queue}")
|
|
471
|
+
if not self.agent.accepts_input:
|
|
472
|
+
raise RuntimeError("agent is shutting down")
|
|
473
|
+
if queue == "steer":
|
|
474
|
+
self.agent.stop_asap()
|
|
475
|
+
future: "asyncio.Future[TurnResult]" = (
|
|
476
|
+
asyncio.get_running_loop().create_future()
|
|
53
477
|
)
|
|
478
|
+
if queue == "steer" and self._steer_queue:
|
|
479
|
+
queued = self._steer_queue[-1]
|
|
480
|
+
queued.texts.append(text)
|
|
481
|
+
queued.futures.append(future)
|
|
482
|
+
return queued.submission_id, future
|
|
54
483
|
|
|
55
|
-
|
|
56
|
-
self.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
484
|
+
submission_id = uuid7_string()
|
|
485
|
+
current = self._current_submission if self.agent.is_running else None
|
|
486
|
+
queued = _QueuedSubmission(
|
|
487
|
+
submission_id=submission_id,
|
|
488
|
+
turn_id=(
|
|
489
|
+
current.turn_id
|
|
490
|
+
if queue == "steer" and current is not None
|
|
491
|
+
else submission_id
|
|
492
|
+
),
|
|
493
|
+
texts=[text],
|
|
494
|
+
futures=[future],
|
|
65
495
|
)
|
|
66
|
-
self.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
current_task.cancel()
|
|
74
|
-
for queued in tuple(self._enqueue_queue):
|
|
75
|
-
self._finish_submission_exception(queued, exc)
|
|
76
|
-
for queued in tuple(self._steer_queue):
|
|
77
|
-
self._finish_submission_exception(queued, exc)
|
|
78
|
-
self._enqueue_queue.clear()
|
|
79
|
-
self._steer_queue.clear()
|
|
80
|
-
self._queue_event.set()
|
|
81
|
-
|
|
82
|
-
async def run_forever(self) -> 'None':
|
|
496
|
+
target = self._steer_queue if queue == "steer" else self._enqueue_queue
|
|
497
|
+
target.append(queued)
|
|
498
|
+
asyncio.get_running_loop().call_soon(self._wake_worker)
|
|
499
|
+
return submission_id, future
|
|
500
|
+
|
|
501
|
+
async def _run_forever(self) -> "None":
|
|
502
|
+
self._queue_event = asyncio.Event()
|
|
83
503
|
while True:
|
|
84
504
|
queued = await self._next_submission()
|
|
85
|
-
|
|
505
|
+
if queued is None:
|
|
506
|
+
break
|
|
86
507
|
self._current_submission = queued
|
|
87
508
|
try:
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
self._agent.run_turn(
|
|
91
|
-
list(submission.op.texts),
|
|
92
|
-
turn_id=queued.turn_id,
|
|
93
|
-
)
|
|
94
|
-
)
|
|
95
|
-
try:
|
|
96
|
-
result = await self._current_task
|
|
97
|
-
except TurnInterrupted:
|
|
98
|
-
self._finish_submission_exception(
|
|
99
|
-
queued,
|
|
100
|
-
RuntimeError("submission interrupted"),
|
|
101
|
-
)
|
|
102
|
-
continue
|
|
103
|
-
except asyncio.CancelledError:
|
|
104
|
-
self._finish_submission_exception(
|
|
105
|
-
queued,
|
|
106
|
-
RuntimeError("submission interrupted"),
|
|
107
|
-
)
|
|
108
|
-
continue
|
|
109
|
-
self._finish_submission_result(queued, result)
|
|
110
|
-
continue
|
|
111
|
-
|
|
112
|
-
if isinstance(submission.op, ShutdownOp):
|
|
113
|
-
self._agent.shutdown()
|
|
114
|
-
self._finish_submission_result(queued, None)
|
|
115
|
-
break
|
|
116
|
-
|
|
117
|
-
self._finish_submission_exception(
|
|
118
|
-
queued,
|
|
119
|
-
RuntimeError(f"unsupported operation: {type(submission.op).__name__}"),
|
|
509
|
+
result = await self.agent.run_turn(
|
|
510
|
+
list(queued.texts), turn_id=queued.turn_id
|
|
120
511
|
)
|
|
121
|
-
except
|
|
512
|
+
except TurnInterrupted:
|
|
513
|
+
self._finish_submission_exception(queued, SubmissionInterrupted())
|
|
514
|
+
except Exception as exc:
|
|
122
515
|
self._finish_submission_exception(queued, exc)
|
|
516
|
+
else:
|
|
517
|
+
self._finish_submission_result(queued, result)
|
|
123
518
|
finally:
|
|
124
|
-
self._current_task = None
|
|
125
519
|
self._current_submission = None
|
|
520
|
+
try:
|
|
521
|
+
async with self._get_command_lock():
|
|
522
|
+
await self._close_resources()
|
|
523
|
+
finally:
|
|
524
|
+
self.agent.shutdown()
|
|
525
|
+
self._publish(SessionClosedEvent())
|
|
126
526
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
async with self._queue_lock:
|
|
145
|
-
if queue == "steer" and self._steer_queue:
|
|
146
|
-
queued = self._steer_queue[-1]
|
|
147
|
-
queued.submission.op.texts.append(text)
|
|
148
|
-
queued.futures.append(future)
|
|
149
|
-
return queued.submission.id, future
|
|
150
|
-
|
|
151
|
-
submission = Submission(id=uuid7_string(), op=UserTurnOp(texts=[text]))
|
|
152
|
-
current = self._current_submission if self._has_active_turn() else None
|
|
153
|
-
turn_id = (
|
|
154
|
-
current.turn_id if queue == "steer" and current is not None else submission.id
|
|
155
|
-
)
|
|
156
|
-
queued = _QueuedSubmission(
|
|
157
|
-
submission=submission,
|
|
158
|
-
turn_id=turn_id,
|
|
159
|
-
futures=[future],
|
|
160
|
-
)
|
|
161
|
-
if queue == "steer":
|
|
162
|
-
self._steer_queue.append(queued)
|
|
163
|
-
else:
|
|
164
|
-
self._enqueue_queue.append(queued)
|
|
165
|
-
self._queue_event.set()
|
|
166
|
-
return submission.id, future
|
|
527
|
+
async def _close_resources(self):
|
|
528
|
+
errors = []
|
|
529
|
+
environment = self.agent.tool_registry.runtime_environment
|
|
530
|
+
for handler in [environment.subagent_manager.shutdown] + self._close_handlers:
|
|
531
|
+
try:
|
|
532
|
+
await handler()
|
|
533
|
+
except Exception as exc:
|
|
534
|
+
errors.append(exc)
|
|
535
|
+
if errors:
|
|
536
|
+
for error in errors[1:]:
|
|
537
|
+
asyncio.get_running_loop().call_exception_handler(
|
|
538
|
+
{
|
|
539
|
+
"message": "Session close handler failed",
|
|
540
|
+
"exception": error,
|
|
541
|
+
}
|
|
542
|
+
)
|
|
543
|
+
raise errors[0]
|
|
167
544
|
|
|
168
|
-
async def _next_submission(self) ->
|
|
545
|
+
async def _next_submission(self) -> "typing.Union[_QueuedSubmission, None]":
|
|
169
546
|
while True:
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
queued = self._enqueue_queue.popleft()
|
|
179
|
-
if queued is not None:
|
|
180
|
-
if not self._steer_queue and not self._enqueue_queue:
|
|
181
|
-
self._queue_event.clear()
|
|
182
|
-
return queued
|
|
183
|
-
self._queue_event.clear()
|
|
547
|
+
await self.agent.wait_until_idle()
|
|
548
|
+
if self._steer_queue:
|
|
549
|
+
return self._steer_queue.popleft()
|
|
550
|
+
if self._enqueue_queue:
|
|
551
|
+
return self._enqueue_queue.popleft()
|
|
552
|
+
if not self.accepts_input:
|
|
553
|
+
return None
|
|
554
|
+
self._queue_event.clear()
|
|
184
555
|
await self._queue_event.wait()
|
|
185
556
|
|
|
186
557
|
@staticmethod
|
|
187
558
|
def _finish_submission_result(
|
|
188
|
-
queued:
|
|
189
|
-
result:
|
|
190
|
-
) ->
|
|
559
|
+
queued: "_QueuedSubmission",
|
|
560
|
+
result: "TurnResult",
|
|
561
|
+
) -> "None":
|
|
191
562
|
for future in queued.futures:
|
|
192
563
|
if not future.done():
|
|
193
564
|
future.set_result(result)
|
|
194
565
|
|
|
195
566
|
@staticmethod
|
|
196
567
|
def _finish_submission_exception(
|
|
197
|
-
queued:
|
|
198
|
-
exc:
|
|
199
|
-
) ->
|
|
568
|
+
queued: "_QueuedSubmission",
|
|
569
|
+
exc: "Exception",
|
|
570
|
+
) -> "None":
|
|
200
571
|
for future in queued.futures:
|
|
201
572
|
if not future.done():
|
|
202
573
|
future.set_exception(exc)
|
|
203
574
|
|
|
204
|
-
def
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
575
|
+
def _handle_agent_event(self, event: "TurnEvent") -> "None":
|
|
576
|
+
queued = self._current_submission
|
|
577
|
+
if queued is not None and event.turn_id == queued.turn_id:
|
|
578
|
+
event = replace(
|
|
579
|
+
event, submission_id=event.submission_id or queued.submission_id
|
|
580
|
+
)
|
|
581
|
+
if isinstance(event, TurnStartedEvent):
|
|
582
|
+
self._active_turn = {
|
|
583
|
+
"turn_id": event.turn_id,
|
|
584
|
+
"submission_id": event.submission_id or event.turn_id,
|
|
585
|
+
"user_text": "\n".join(event.user_texts),
|
|
586
|
+
"user_texts": list(event.user_texts),
|
|
587
|
+
"assistant_text": "",
|
|
588
|
+
"completed_history": conversation_history_to_turns(self.agent.history),
|
|
589
|
+
}
|
|
590
|
+
elif self._active_turn is not None:
|
|
591
|
+
if isinstance(event, AssistantDeltaEvent):
|
|
592
|
+
self._active_turn["assistant_text"] += event.delta
|
|
593
|
+
elif isinstance(event, (StreamErrorEvent, ToolCompletedEvent)):
|
|
594
|
+
self._active_turn["assistant_text"] = ""
|
|
595
|
+
elif isinstance(event, AutoCompactCompletedEvent):
|
|
596
|
+
self._active_turn["completed_history"] = conversation_history_to_turns(
|
|
597
|
+
self.agent.history
|
|
598
|
+
)
|
|
599
|
+
elif isinstance(
|
|
600
|
+
event, (TurnCompletedEvent, TurnFailedEvent, TurnInterruptedEvent)
|
|
601
|
+
):
|
|
602
|
+
self._active_turn = None
|
|
603
|
+
if isinstance(event, TurnStartedEvent) and not self.title:
|
|
604
|
+
prompt = "\n".join(event.user_texts)
|
|
605
|
+
if prompt:
|
|
606
|
+
self.title = shorten_title(prompt)
|
|
607
|
+
self.publish_state("auto_title")
|
|
608
|
+
if isinstance(event, TerminalEvent) and event.background_work_count is not None:
|
|
609
|
+
self._background_work_count = event.background_work_count
|
|
610
|
+
if self.agent.recorded_session_file_path != self._recorded_rollout_path:
|
|
611
|
+
self.publish_state("recording")
|
|
612
|
+
self._publish(event)
|
|
210
613
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
614
|
+
def _publish(self, event: "Event"):
|
|
615
|
+
for handler in (self.event_handler,) + tuple(self._frontends.values()):
|
|
616
|
+
self._notify_handler(handler, event)
|
|
214
617
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
618
|
+
@staticmethod
|
|
619
|
+
def _notify_handler(handler, event):
|
|
620
|
+
try:
|
|
621
|
+
handler(event)
|
|
622
|
+
except (Exception, asyncio.CancelledError) as exc:
|
|
623
|
+
asyncio.get_running_loop().call_exception_handler(
|
|
624
|
+
{
|
|
625
|
+
"message": "Submission event observer failed: " + event.kind,
|
|
626
|
+
"exception": exc,
|
|
627
|
+
}
|
|
628
|
+
)
|