mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
Add dedicated Python goal operations
This commit is contained in:
@@ -150,16 +150,20 @@ attempt. API-key login completes synchronously and does not return a handle.
|
||||
|
||||
### Thread
|
||||
|
||||
- `run(input: str | Input, *, goal: bool = False, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox: Sandbox | None = None, service_tier=None, summary=None) -> TurnResult`
|
||||
- `turn(input: str | Input, *, goal: bool = False, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox: Sandbox | None = None, service_tier=None, summary=None) -> TurnHandle`
|
||||
- `run(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox: Sandbox | None = None, service_tier=None, summary=None) -> TurnResult`
|
||||
- `turn(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox: Sandbox | None = None, service_tier=None, summary=None) -> TurnHandle`
|
||||
- `run_goal(objective: str) -> TurnResult`
|
||||
- `start_goal(objective: str) -> TurnHandle`
|
||||
- `read(*, include_turns: bool = False) -> ThreadReadResponse`
|
||||
- `set_name(name: str) -> ThreadSetNameResponse`
|
||||
- `compact() -> ThreadCompactStartResponse`
|
||||
|
||||
### AsyncThread
|
||||
|
||||
- `run(input: str | Input, *, goal: bool = False, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox: Sandbox | None = None, service_tier=None, summary=None) -> Awaitable[TurnResult]`
|
||||
- `turn(input: str | Input, *, goal: bool = False, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox: Sandbox | None = None, service_tier=None, summary=None) -> Awaitable[AsyncTurnHandle]`
|
||||
- `run(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox: Sandbox | None = None, service_tier=None, summary=None) -> Awaitable[TurnResult]`
|
||||
- `turn(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox: Sandbox | None = None, service_tier=None, summary=None) -> Awaitable[AsyncTurnHandle]`
|
||||
- `run_goal(objective: str) -> Awaitable[TurnResult]`
|
||||
- `start_goal(objective: str) -> Awaitable[AsyncTurnHandle]`
|
||||
- `read(*, include_turns: bool = False) -> Awaitable[ThreadReadResponse]`
|
||||
- `set_name(name: str) -> Awaitable[ThreadSetNameResponse]`
|
||||
- `compact() -> Awaitable[ThreadCompactStartResponse]`
|
||||
@@ -184,9 +188,11 @@ phase-less assistant message item.
|
||||
Use `turn(...)` when you need low-level turn control (`stream()`, `steer()`,
|
||||
`interrupt()`) before collecting the turn result.
|
||||
|
||||
Pass `goal=True` to either method for an objective that can continue through
|
||||
multiple internal turns. Streaming, steering, interruption, and the returned
|
||||
`TurnResult` still present one logical turn with one stable ID.
|
||||
Use `run_goal(...)` or `start_goal(...)` for an objective that can continue
|
||||
through multiple internal turns. Goal operations require an idle, persisted
|
||||
thread and use its existing configuration. Starting one replaces any stored
|
||||
goal. Streaming, steering, interruption, and the returned `TurnResult` still
|
||||
present one logical turn with one stable ID.
|
||||
|
||||
## Sandbox
|
||||
|
||||
|
||||
@@ -72,15 +72,16 @@ with Codex() as codex:
|
||||
Use `Thread.turn(...)` when you need a `TurnHandle` for streaming, steering,
|
||||
or interrupting an active turn.
|
||||
|
||||
For a longer objective, opt into goal mode on the operation producing the
|
||||
result:
|
||||
For a longer objective on an idle, persisted thread, run a dedicated goal
|
||||
operation:
|
||||
|
||||
```python
|
||||
result = thread.run("Improve the benchmark coverage in this repository.", goal=True)
|
||||
result = thread.run_goal("Improve the benchmark coverage in this repository.")
|
||||
```
|
||||
|
||||
Goal mode may continue working internally, but it streams and returns as one
|
||||
logical turn.
|
||||
A goal may continue working internally, but it streams and returns as one
|
||||
logical turn. It uses the thread's existing configuration and replaces any
|
||||
stored goal.
|
||||
|
||||
## 4. Choose Sandbox Access
|
||||
|
||||
|
||||
@@ -17,10 +17,8 @@ from openai_codex import AsyncCodex
|
||||
async def main() -> None:
|
||||
async with AsyncCodex(config=runtime_config()) as codex:
|
||||
thread = await codex.thread_start()
|
||||
result = await thread.run(
|
||||
"Improve the benchmark coverage in this repository.",
|
||||
goal=True,
|
||||
)
|
||||
goal = await thread.start_goal("Improve the benchmark coverage in this repository.")
|
||||
result = await goal.run()
|
||||
print(result.final_response)
|
||||
|
||||
|
||||
|
||||
@@ -13,5 +13,5 @@ from openai_codex import Codex
|
||||
|
||||
with Codex(config=runtime_config()) as codex:
|
||||
thread = codex.thread_start()
|
||||
result = thread.run("Improve the benchmark coverage in this repository.", goal=True)
|
||||
result = thread.run_goal("Improve the benchmark coverage in this repository.")
|
||||
print(result.final_response)
|
||||
|
||||
@@ -527,18 +527,6 @@ def _normalized_schema_bundle_text(schema_dir: Path) -> str:
|
||||
schema = json.loads(schema_bundle_path(schema_dir).read_text())
|
||||
definitions = schema.get("definitions", {})
|
||||
if isinstance(definitions, dict):
|
||||
turn_start = definitions.get("TurnStartParams")
|
||||
if isinstance(turn_start, dict):
|
||||
properties = turn_start.get("properties")
|
||||
if isinstance(properties, dict):
|
||||
properties["goal"] = {
|
||||
"default": False,
|
||||
"description": (
|
||||
"Replace the thread's active goal with an objective derived "
|
||||
"from this turn's text input."
|
||||
),
|
||||
"type": "boolean",
|
||||
}
|
||||
for definition in definitions.values():
|
||||
if isinstance(definition, dict):
|
||||
_flatten_string_enum_one_of(definition)
|
||||
@@ -1121,7 +1109,6 @@ def _render_thread_block(
|
||||
" self,",
|
||||
" input: RunInput,",
|
||||
" *,",
|
||||
" goal: bool = False,",
|
||||
*_approval_mode_override_signature_lines(),
|
||||
*_kw_signature_lines(turn_fields),
|
||||
" ) -> TurnHandle:",
|
||||
@@ -1134,16 +1121,8 @@ def _render_thread_block(
|
||||
*_approval_mode_model_arg_lines(),
|
||||
*_model_arg_lines(turn_fields),
|
||||
" )",
|
||||
" goal_state = self._client.register_goal_operation(self.id) if goal else None",
|
||||
" try:",
|
||||
" turn = self._client.turn_start(self.id, wire_input, params=params, goal=goal)",
|
||||
" except BaseException:",
|
||||
" if goal_state is not None:",
|
||||
" self._client.unregister_goal_operation(goal_state)",
|
||||
" raise",
|
||||
" if goal_state is not None:",
|
||||
" self._client.bind_goal_operation(goal_state, turn.turn.id)",
|
||||
" return TurnHandle(self._client, self.id, turn.turn.id, _goal=goal_state)",
|
||||
" turn = self._client.turn_start(self.id, wire_input, params=params)",
|
||||
" return TurnHandle(self._client, self.id, turn.turn.id)",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -1156,7 +1135,6 @@ def _render_async_thread_block(
|
||||
" self,",
|
||||
" input: RunInput,",
|
||||
" *,",
|
||||
" goal: bool = False,",
|
||||
*_approval_mode_override_signature_lines(),
|
||||
*_kw_signature_lines(turn_fields),
|
||||
" ) -> AsyncTurnHandle:",
|
||||
@@ -1170,21 +1148,12 @@ def _render_async_thread_block(
|
||||
*_approval_mode_model_arg_lines(),
|
||||
*_model_arg_lines(turn_fields),
|
||||
" )",
|
||||
" goal_state = self._codex._client.register_goal_operation(self.id) if goal else None",
|
||||
" try:",
|
||||
" turn = await self._codex._client.turn_start(",
|
||||
" self.id,",
|
||||
" wire_input,",
|
||||
" params=params,",
|
||||
" goal=goal,",
|
||||
" )",
|
||||
" except BaseException:",
|
||||
" if goal_state is not None:",
|
||||
" self._codex._client.unregister_goal_operation(goal_state)",
|
||||
" raise",
|
||||
" if goal_state is not None:",
|
||||
" self._codex._client.bind_goal_operation(goal_state, turn.turn.id)",
|
||||
" return AsyncTurnHandle(self._codex, self.id, turn.turn.id, _goal=goal_state)",
|
||||
" turn = await self._codex._client.turn_start(",
|
||||
" self.id,",
|
||||
" wire_input,",
|
||||
" params=params,",
|
||||
" )",
|
||||
" return AsyncTurnHandle(self._codex, self.id, turn.turn.id)",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -1226,9 +1195,7 @@ def generate_public_api_flat_methods() -> None:
|
||||
turn_start_fields = _load_public_fields(
|
||||
"openai_codex.generated.v2_all",
|
||||
"TurnStartParams",
|
||||
# `goal` has a stable bool default and private routing setup, so render
|
||||
# it explicitly rather than inheriting the generated model default.
|
||||
exclude={"thread_id", "input", "client_user_message_id", "goal", *approval_fields},
|
||||
exclude={"thread_id", "input", "client_user_message_id", *approval_fields},
|
||||
)
|
||||
turn_start_fields = _replace_public_sandbox_field(turn_start_fields, wire_name="sandbox_policy")
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from typing import AsyncIterator, Awaitable, Callable, Iterator
|
||||
@@ -55,17 +56,12 @@ class _GoalOperationState:
|
||||
_failure: BaseException | None = None
|
||||
_finished: bool = False
|
||||
|
||||
def bind(self, logical_turn_id: str) -> None:
|
||||
with self._condition:
|
||||
self.logical_turn_id = logical_turn_id
|
||||
if self.current_turn_id is None and self.completed_turn is None:
|
||||
self.current_turn_id = logical_turn_id
|
||||
self._condition.notify_all()
|
||||
|
||||
def observe(self, notification: Notification) -> None:
|
||||
payload = notification.payload
|
||||
with self._condition:
|
||||
if isinstance(payload, TurnStartedNotification):
|
||||
if self.logical_turn_id is None:
|
||||
self.logical_turn_id = payload.turn.id
|
||||
self.current_turn_id = payload.turn.id
|
||||
if self.started_turn is None:
|
||||
self.started_turn = payload.turn
|
||||
@@ -75,6 +71,8 @@ class _GoalOperationState:
|
||||
self.current_turn_id = None
|
||||
elif isinstance(payload, ThreadGoalUpdatedNotification):
|
||||
self.status = payload.goal.status
|
||||
if self.status == ThreadGoalStatus.active:
|
||||
self.cleared = False
|
||||
elif isinstance(payload, ThreadGoalClearedNotification):
|
||||
self.cleared = True
|
||||
elif isinstance(payload, ItemCompletedNotification):
|
||||
@@ -93,6 +91,19 @@ class _GoalOperationState:
|
||||
self._condition.notify_all()
|
||||
self._notifications.put(notification)
|
||||
|
||||
def wait_for_start(self, timeout: float) -> str | None:
|
||||
"""Wait for the runtime-generated first turn without consuming its event."""
|
||||
deadline = time.monotonic() + timeout
|
||||
with self._condition:
|
||||
while self.started_turn is None or self.logical_turn_id is None:
|
||||
if self._failure is not None:
|
||||
raise self._failure
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return None
|
||||
self._condition.wait(remaining)
|
||||
return self.logical_turn_id
|
||||
|
||||
def fail(self, exc: BaseException) -> None:
|
||||
with self._condition:
|
||||
self._failure = exc
|
||||
@@ -256,6 +267,8 @@ class _GoalStreamCursor:
|
||||
events = [_logical_notification(notification, logical_turn_id)]
|
||||
if isinstance(payload, ThreadGoalUpdatedNotification):
|
||||
self.status = payload.goal.status
|
||||
if self.status == ThreadGoalStatus.active:
|
||||
self.cleared = False
|
||||
events = []
|
||||
elif isinstance(payload, ThreadGoalClearedNotification):
|
||||
self.cleared = True
|
||||
|
||||
@@ -224,22 +224,8 @@ class MessageRouter:
|
||||
goal_operation.fail(exc)
|
||||
self._global_notifications.put(exc)
|
||||
|
||||
def _notification_login_id(self, notification: Notification) -> str | None:
|
||||
"""Extract the login attempt id from completion notifications."""
|
||||
if notification.method != "account/login/completed":
|
||||
return None
|
||||
|
||||
payload = notification.payload
|
||||
if isinstance(payload, AccountLoginCompletedNotification):
|
||||
return payload.login_id
|
||||
if isinstance(payload, UnknownNotification):
|
||||
raw_login_id = payload.params.get("loginId")
|
||||
if isinstance(raw_login_id, str):
|
||||
return raw_login_id
|
||||
return None
|
||||
|
||||
def _notification_turn_id(self, notification: Notification) -> str | None:
|
||||
"""Extract routing ids from known generated payloads or raw unknown payloads."""
|
||||
"""Extract routing ids from generated metadata or raw unknown payloads."""
|
||||
payload = notification.payload
|
||||
if isinstance(payload, UnknownNotification):
|
||||
raw_turn_id = payload.params.get("turnId")
|
||||
@@ -254,9 +240,23 @@ class MessageRouter:
|
||||
return notification_turn_id(payload)
|
||||
|
||||
def _notification_thread_id(self, notification: Notification) -> str | None:
|
||||
"""Extract thread ids from known generated payloads or raw payloads."""
|
||||
"""Extract thread ids from generated metadata or raw unknown payloads."""
|
||||
payload = notification.payload
|
||||
if isinstance(payload, UnknownNotification):
|
||||
raw_thread_id = payload.params.get("threadId")
|
||||
return raw_thread_id if isinstance(raw_thread_id, str) else None
|
||||
return notification_thread_id(payload)
|
||||
|
||||
def _notification_login_id(self, notification: Notification) -> str | None:
|
||||
"""Extract the login attempt id from completion notifications."""
|
||||
if notification.method != "account/login/completed":
|
||||
return None
|
||||
|
||||
payload = notification.payload
|
||||
if isinstance(payload, AccountLoginCompletedNotification):
|
||||
return payload.login_id
|
||||
if isinstance(payload, UnknownNotification):
|
||||
raw_login_id = payload.params.get("loginId")
|
||||
if isinstance(raw_login_id, str):
|
||||
return raw_login_id
|
||||
return None
|
||||
|
||||
@@ -79,6 +79,15 @@ from .generated.v2_all import (
|
||||
from .models import InitializeResponse, JsonObject, Notification
|
||||
|
||||
|
||||
def _normalize_goal_objective(objective: str) -> str:
|
||||
if not isinstance(objective, str):
|
||||
raise TypeError("goal objective must be a string")
|
||||
objective = objective.strip()
|
||||
if not objective:
|
||||
raise ValueError("goal objective must not be empty")
|
||||
return objective
|
||||
|
||||
|
||||
def _active_turn_id_from_error(exc: InvalidRequestError) -> str | None:
|
||||
match = re.search(r" but found `?([^`]+)`?$", exc.message)
|
||||
return match.group(1) if match is not None else None
|
||||
@@ -561,7 +570,6 @@ class Thread:
|
||||
self,
|
||||
input: RunInput,
|
||||
*,
|
||||
goal: bool = False,
|
||||
approval_mode: ApprovalMode | None = None,
|
||||
cwd: str | None = None,
|
||||
effort: ReasoningEffort | None = None,
|
||||
@@ -575,7 +583,6 @@ class Thread:
|
||||
"""Run a complete turn and collect its final result."""
|
||||
turn = self.turn(
|
||||
input,
|
||||
goal=goal,
|
||||
approval_mode=approval_mode,
|
||||
cwd=cwd,
|
||||
effort=effort,
|
||||
@@ -592,12 +599,15 @@ class Thread:
|
||||
finally:
|
||||
stream.close()
|
||||
|
||||
def run_goal(self, objective: str) -> TurnResult:
|
||||
"""Run a persisted goal to completion as one logical turn."""
|
||||
return self.start_goal(objective).run()
|
||||
|
||||
# BEGIN GENERATED: Thread.flat_methods
|
||||
def turn(
|
||||
self,
|
||||
input: RunInput,
|
||||
*,
|
||||
goal: bool = False,
|
||||
approval_mode: ApprovalMode | None = None,
|
||||
cwd: str | None = None,
|
||||
effort: ReasoningEffort | None = None,
|
||||
@@ -625,19 +635,17 @@ class Thread:
|
||||
service_tier=service_tier,
|
||||
summary=summary,
|
||||
)
|
||||
goal_state = self._client.register_goal_operation(self.id) if goal else None
|
||||
try:
|
||||
turn = self._client.turn_start(self.id, wire_input, params=params, goal=goal)
|
||||
except BaseException:
|
||||
if goal_state is not None:
|
||||
self._client.unregister_goal_operation(goal_state)
|
||||
raise
|
||||
if goal_state is not None:
|
||||
self._client.bind_goal_operation(goal_state, turn.turn.id)
|
||||
return TurnHandle(self._client, self.id, turn.turn.id, _goal=goal_state)
|
||||
turn = self._client.turn_start(self.id, wire_input, params=params)
|
||||
return TurnHandle(self._client, self.id, turn.turn.id)
|
||||
|
||||
# END GENERATED: Thread.flat_methods
|
||||
|
||||
def start_goal(self, objective: str) -> TurnHandle:
|
||||
"""Activate a persisted goal and return its logical turn handle."""
|
||||
objective = _normalize_goal_objective(objective)
|
||||
state, turn_id = self._client.start_goal_operation(self.id, objective)
|
||||
return TurnHandle(self._client, self.id, turn_id, _goal=state)
|
||||
|
||||
def read(self, *, include_turns: bool = False) -> ThreadReadResponse:
|
||||
"""Read this thread, optionally including its turn history."""
|
||||
return self._client.thread_read(self.id, include_turns=include_turns)
|
||||
@@ -660,7 +668,6 @@ class AsyncThread:
|
||||
self,
|
||||
input: RunInput,
|
||||
*,
|
||||
goal: bool = False,
|
||||
approval_mode: ApprovalMode | None = None,
|
||||
cwd: str | None = None,
|
||||
effort: ReasoningEffort | None = None,
|
||||
@@ -674,7 +681,6 @@ class AsyncThread:
|
||||
"""Run a complete turn asynchronously and collect its final result."""
|
||||
turn = await self.turn(
|
||||
input,
|
||||
goal=goal,
|
||||
approval_mode=approval_mode,
|
||||
cwd=cwd,
|
||||
effort=effort,
|
||||
@@ -691,12 +697,16 @@ class AsyncThread:
|
||||
finally:
|
||||
await stream.aclose()
|
||||
|
||||
async def run_goal(self, objective: str) -> TurnResult:
|
||||
"""Run a persisted goal asynchronously as one logical turn."""
|
||||
goal = await self.start_goal(objective)
|
||||
return await goal.run()
|
||||
|
||||
# BEGIN GENERATED: AsyncThread.flat_methods
|
||||
async def turn(
|
||||
self,
|
||||
input: RunInput,
|
||||
*,
|
||||
goal: bool = False,
|
||||
approval_mode: ApprovalMode | None = None,
|
||||
cwd: str | None = None,
|
||||
effort: ReasoningEffort | None = None,
|
||||
@@ -725,24 +735,22 @@ class AsyncThread:
|
||||
service_tier=service_tier,
|
||||
summary=summary,
|
||||
)
|
||||
goal_state = self._codex._client.register_goal_operation(self.id) if goal else None
|
||||
try:
|
||||
turn = await self._codex._client.turn_start(
|
||||
self.id,
|
||||
wire_input,
|
||||
params=params,
|
||||
goal=goal,
|
||||
)
|
||||
except BaseException:
|
||||
if goal_state is not None:
|
||||
self._codex._client.unregister_goal_operation(goal_state)
|
||||
raise
|
||||
if goal_state is not None:
|
||||
self._codex._client.bind_goal_operation(goal_state, turn.turn.id)
|
||||
return AsyncTurnHandle(self._codex, self.id, turn.turn.id, _goal=goal_state)
|
||||
turn = await self._codex._client.turn_start(
|
||||
self.id,
|
||||
wire_input,
|
||||
params=params,
|
||||
)
|
||||
return AsyncTurnHandle(self._codex, self.id, turn.turn.id)
|
||||
|
||||
# END GENERATED: AsyncThread.flat_methods
|
||||
|
||||
async def start_goal(self, objective: str) -> AsyncTurnHandle:
|
||||
"""Activate a persisted goal and return its async logical turn handle."""
|
||||
await self._codex._ensure_initialized()
|
||||
objective = _normalize_goal_objective(objective)
|
||||
state, turn_id = await self._codex._client.start_goal_operation(self.id, objective)
|
||||
return AsyncTurnHandle(self._codex, self.id, turn_id, _goal=state)
|
||||
|
||||
async def read(self, *, include_turns: bool = False) -> ThreadReadResponse:
|
||||
"""Read this thread, optionally including its turn history."""
|
||||
await self._codex._ensure_initialized()
|
||||
|
||||
@@ -22,7 +22,9 @@ from .generated.v2_all import (
|
||||
ThreadCompactStartResponse,
|
||||
ThreadForkParams as V2ThreadForkParams,
|
||||
ThreadForkResponse,
|
||||
ThreadGoalClearResponse,
|
||||
ThreadGoalSetResponse,
|
||||
ThreadGoalStatus,
|
||||
ThreadListParams as V2ThreadListParams,
|
||||
ThreadListResponse,
|
||||
ThreadReadResponse,
|
||||
@@ -113,10 +115,6 @@ class AsyncCodexClient:
|
||||
"""Register a logical goal route on the wrapped sync client."""
|
||||
return self._sync.register_goal_operation(thread_id)
|
||||
|
||||
def bind_goal_operation(self, state: _GoalOperationState, turn_id: str) -> None:
|
||||
"""Bind a logical goal route to its stable turn id."""
|
||||
self._sync.bind_goal_operation(state, turn_id)
|
||||
|
||||
def unregister_goal_operation(self, state: _GoalOperationState) -> None:
|
||||
"""Release one logical goal route."""
|
||||
self._sync.unregister_goal_operation(state)
|
||||
@@ -206,17 +204,63 @@ class AsyncCodexClient:
|
||||
"""Start thread compaction using the wrapped sync client."""
|
||||
return await self._call_sync(self._sync.thread_compact, thread_id)
|
||||
|
||||
async def thread_goal_clear(self, thread_id: str) -> ThreadGoalClearResponse:
|
||||
"""Clear the persisted goal through the wrapped sync client."""
|
||||
return await self._call_sync(self._sync.thread_goal_clear, thread_id)
|
||||
|
||||
async def thread_goal_set(
|
||||
self,
|
||||
thread_id: str,
|
||||
*,
|
||||
objective: str | None = None,
|
||||
status: ThreadGoalStatus | None = None,
|
||||
) -> ThreadGoalSetResponse:
|
||||
"""Create or update a persisted goal through the wrapped sync client."""
|
||||
return await self._call_sync(
|
||||
self._sync.thread_goal_set,
|
||||
thread_id,
|
||||
objective=objective,
|
||||
status=status,
|
||||
)
|
||||
|
||||
async def pause_goal(self, thread_id: str) -> ThreadGoalSetResponse:
|
||||
"""Pause the active goal through the wrapped sync client."""
|
||||
return await self._call_sync(self._sync.pause_goal, thread_id)
|
||||
|
||||
async def start_goal_operation(
|
||||
self,
|
||||
thread_id: str,
|
||||
objective: str,
|
||||
) -> tuple[_GoalOperationState, str]:
|
||||
"""Start a logical goal through the wrapped sync client."""
|
||||
operation = asyncio.create_task(
|
||||
asyncio.to_thread(
|
||||
self._sync.start_goal_operation,
|
||||
thread_id,
|
||||
objective,
|
||||
)
|
||||
)
|
||||
try:
|
||||
return await asyncio.shield(operation)
|
||||
except asyncio.CancelledError:
|
||||
try:
|
||||
state, _ = await operation
|
||||
except BaseException:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
await self.pause_goal(thread_id)
|
||||
except Exception:
|
||||
pass
|
||||
state.finish()
|
||||
self.unregister_goal_operation(state)
|
||||
raise
|
||||
|
||||
async def turn_start(
|
||||
self,
|
||||
thread_id: str,
|
||||
input_items: list[JsonObject] | JsonObject | str,
|
||||
params: V2TurnStartParams | JsonObject | None = None,
|
||||
*,
|
||||
goal: bool = False,
|
||||
) -> TurnStartResponse:
|
||||
"""Start a turn using the wrapped sync client."""
|
||||
return await self._call_sync(
|
||||
@@ -224,7 +268,6 @@ class AsyncCodexClient:
|
||||
thread_id,
|
||||
input_items,
|
||||
params,
|
||||
goal=goal,
|
||||
)
|
||||
|
||||
async def turn_interrupt(self, thread_id: str, turn_id: str) -> TurnInterruptResponse:
|
||||
|
||||
@@ -13,7 +13,7 @@ from pydantic import BaseModel
|
||||
from ._goal import _GoalOperationState
|
||||
from ._message_router import MessageRouter
|
||||
from ._version import __version__ as SDK_VERSION
|
||||
from .errors import CodexError, TransportClosedError
|
||||
from .errors import CodexError, InvalidRequestError, TransportClosedError
|
||||
from .generated.notification_registry import NOTIFICATION_MODELS
|
||||
from .generated.v2_all import (
|
||||
AccountLoginCompletedNotification,
|
||||
@@ -23,6 +23,7 @@ from .generated.v2_all import (
|
||||
ChatgptLoginAccountResponse,
|
||||
GetAccountParams as V2GetAccountParams,
|
||||
GetAccountResponse,
|
||||
IdleThreadStatus,
|
||||
LoginAccountParams as V2LoginAccountParams,
|
||||
LoginAccountResponse,
|
||||
LogoutAccountResponse,
|
||||
@@ -31,6 +32,7 @@ from .generated.v2_all import (
|
||||
ThreadCompactStartResponse,
|
||||
ThreadForkParams as V2ThreadForkParams,
|
||||
ThreadForkResponse,
|
||||
ThreadGoalClearResponse,
|
||||
ThreadGoalSetResponse,
|
||||
ThreadGoalStatus,
|
||||
ThreadListParams as V2ThreadListParams,
|
||||
@@ -60,6 +62,7 @@ from .retry import retry_on_overload
|
||||
ModelT = TypeVar("ModelT", bound=BaseModel)
|
||||
ApprovalHandler = Callable[[str, JsonObject | None], JsonObject]
|
||||
RUNTIME_PKG_NAME = "openai-codex-cli-bin"
|
||||
_GOAL_START_TIMEOUT_S = 30.0
|
||||
|
||||
|
||||
def _params_dict(
|
||||
@@ -359,11 +362,6 @@ class CodexClient:
|
||||
"""Register a private thread-scoped route for a logical goal turn."""
|
||||
return self._router.register_goal(thread_id)
|
||||
|
||||
def bind_goal_operation(self, state: _GoalOperationState, turn_id: str) -> None:
|
||||
"""Bind a pending goal route to its stable logical turn id."""
|
||||
state.bind(turn_id)
|
||||
self.unregister_turn_notifications(turn_id)
|
||||
|
||||
def unregister_goal_operation(self, state: _GoalOperationState) -> None:
|
||||
"""Release routing state for one logical goal turn."""
|
||||
self._router.unregister_goal(state)
|
||||
@@ -472,21 +470,87 @@ class CodexClient:
|
||||
response_model=ThreadCompactStartResponse,
|
||||
)
|
||||
|
||||
def pause_goal(self, thread_id: str) -> ThreadGoalSetResponse:
|
||||
"""Pause the active goal used by a logical goal turn."""
|
||||
def thread_goal_clear(self, thread_id: str) -> ThreadGoalClearResponse:
|
||||
"""Clear the persisted goal for a thread before replacing it."""
|
||||
return self.request(
|
||||
"thread/goal/clear",
|
||||
{"threadId": thread_id},
|
||||
response_model=ThreadGoalClearResponse,
|
||||
)
|
||||
|
||||
def thread_goal_set(
|
||||
self,
|
||||
thread_id: str,
|
||||
*,
|
||||
objective: str | None = None,
|
||||
status: ThreadGoalStatus | None = None,
|
||||
) -> ThreadGoalSetResponse:
|
||||
"""Create or update the persisted goal for a thread."""
|
||||
payload: JsonObject = {"threadId": thread_id}
|
||||
if objective is not None:
|
||||
payload["objective"] = objective
|
||||
if status is not None:
|
||||
payload["status"] = status.value
|
||||
return self.request(
|
||||
"thread/goal/set",
|
||||
{"threadId": thread_id, "status": ThreadGoalStatus.paused.value},
|
||||
payload,
|
||||
response_model=ThreadGoalSetResponse,
|
||||
)
|
||||
|
||||
def pause_goal(self, thread_id: str) -> ThreadGoalSetResponse:
|
||||
"""Pause the active goal used by a logical goal turn."""
|
||||
return self.thread_goal_set(thread_id, status=ThreadGoalStatus.paused)
|
||||
|
||||
def start_goal_operation(
|
||||
self,
|
||||
thread_id: str,
|
||||
objective: str,
|
||||
) -> tuple[_GoalOperationState, str]:
|
||||
"""Start a logical goal and wait for its runtime-generated first turn."""
|
||||
thread = self.thread_read(thread_id).thread
|
||||
if not isinstance(thread.status.root, IdleThreadStatus):
|
||||
raise InvalidRequestError(
|
||||
-32600,
|
||||
f"thread must be idle before starting a goal: {thread_id}",
|
||||
)
|
||||
if thread.ephemeral or thread.path is None:
|
||||
raise InvalidRequestError(
|
||||
-32600,
|
||||
f"thread must be persisted before starting a goal: {thread_id}",
|
||||
)
|
||||
|
||||
self.thread_goal_clear(thread_id)
|
||||
state = self.register_goal_operation(thread_id)
|
||||
activated = False
|
||||
try:
|
||||
self.thread_goal_set(
|
||||
thread_id,
|
||||
objective=objective,
|
||||
status=ThreadGoalStatus.active,
|
||||
)
|
||||
activated = True
|
||||
turn_id = state.wait_for_start(_GOAL_START_TIMEOUT_S)
|
||||
if turn_id is None:
|
||||
raise CodexError(
|
||||
"timed out waiting for goal turn to start after "
|
||||
f"{int(_GOAL_START_TIMEOUT_S)} seconds"
|
||||
)
|
||||
return state, turn_id
|
||||
except BaseException:
|
||||
if activated:
|
||||
try:
|
||||
self.pause_goal(thread_id)
|
||||
except Exception:
|
||||
pass
|
||||
state.finish()
|
||||
self.unregister_goal_operation(state)
|
||||
raise
|
||||
|
||||
def turn_start(
|
||||
self,
|
||||
thread_id: str,
|
||||
input_items: list[JsonObject] | JsonObject | str,
|
||||
params: V2TurnStartParams | JsonObject | None = None,
|
||||
*,
|
||||
goal: bool = False,
|
||||
) -> TurnStartResponse:
|
||||
"""Start a turn and register its notification queue as early as possible."""
|
||||
payload = {
|
||||
@@ -494,11 +558,6 @@ class CodexClient:
|
||||
"threadId": thread_id,
|
||||
"input": self._normalize_input_items(input_items),
|
||||
}
|
||||
params_goal = payload.pop("goal", False)
|
||||
if not isinstance(params_goal, bool):
|
||||
raise TypeError("turn/start goal must be a bool")
|
||||
if goal or params_goal:
|
||||
payload["goal"] = True
|
||||
started = self.request("turn/start", payload, response_model=TurnStartResponse)
|
||||
self.register_turn_notifications(started.turn.id)
|
||||
return started
|
||||
|
||||
@@ -204,7 +204,7 @@ class MockResponsesServer:
|
||||
|
||||
|
||||
class AppServerHarness:
|
||||
"""Test fixture that points an app-server process at MockResponsesServer."""
|
||||
"""Test fixture that points a pinned runtime app-server at MockResponsesServer."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -232,10 +232,9 @@ class AppServerHarness:
|
||||
shutil.rmtree(self.codex_home, ignore_errors=True)
|
||||
shutil.rmtree(self.workspace, ignore_errors=True)
|
||||
|
||||
def app_server_config(self, *, codex_bin: str | None = None) -> CodexConfig:
|
||||
"""Build SDK config for an isolated app-server process."""
|
||||
def app_server_config(self) -> CodexConfig:
|
||||
"""Build SDK config for an isolated pinned-runtime app-server process."""
|
||||
return CodexConfig(
|
||||
codex_bin=codex_bin,
|
||||
cwd=str(self.workspace),
|
||||
env={
|
||||
"CODEX_HOME": str(self.codex_home),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from app_server_harness import (
|
||||
@@ -17,30 +16,17 @@ from app_server_helpers import (
|
||||
streaming_response,
|
||||
)
|
||||
|
||||
from openai_codex import AsyncCodex, Codex, CodexConfig, TextInput
|
||||
from openai_codex import AsyncCodex, Codex
|
||||
from openai_codex.errors import InvalidRequestError, TransportClosedError
|
||||
from openai_codex.generated.notification_registry import notification_turn_id
|
||||
from openai_codex.generated.v2_all import (
|
||||
AgentMessageDeltaNotification,
|
||||
ThreadGoalGetResponse,
|
||||
ThreadGoalSetResponse,
|
||||
ThreadGoalStatus,
|
||||
TurnCompletedNotification,
|
||||
TurnStatus,
|
||||
)
|
||||
|
||||
SOURCE_CODEX_BIN = os.environ.get("CODEX_EXEC_PATH")
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
SOURCE_CODEX_BIN is None,
|
||||
reason="requires CODEX_EXEC_PATH pointing to the checkout-built Codex binary",
|
||||
)
|
||||
|
||||
|
||||
def _source_config(harness: AppServerHarness) -> CodexConfig:
|
||||
assert SOURCE_CODEX_BIN is not None
|
||||
return harness.app_server_config(codex_bin=SOURCE_CODEX_BIN)
|
||||
|
||||
|
||||
def _enqueue_completed_goal(
|
||||
harness: AppServerHarness,
|
||||
@@ -189,29 +175,14 @@ def test_sync_goal_run_aggregates_automatic_continuation(tmp_path) -> None:
|
||||
final_text="Goal complete.",
|
||||
)
|
||||
|
||||
with Codex(config=_source_config(harness)) as codex:
|
||||
with Codex(config=harness.app_server_config()) as codex:
|
||||
thread = codex.thread_start()
|
||||
turn = thread.turn(
|
||||
[
|
||||
TextInput(" Improve benchmark coverage "),
|
||||
TextInput("Document the results"),
|
||||
],
|
||||
goal=True,
|
||||
model="goal-test-model",
|
||||
output_schema={
|
||||
"type": "object",
|
||||
"properties": {"summary": {"type": "string"}},
|
||||
"required": ["summary"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
)
|
||||
result = turn.run()
|
||||
result = thread.run_goal(" Improve benchmark coverage ")
|
||||
requests = harness.responses.wait_for_requests(3)
|
||||
|
||||
usage = result.usage.model_dump(by_alias=True, mode="json") if result.usage else None
|
||||
first_body = requests[0].body_json()
|
||||
assert {
|
||||
"id": result.id,
|
||||
"id_is_present": bool(result.id),
|
||||
"status": result.status,
|
||||
"messages": agent_message_texts_from_items(result.items),
|
||||
"final_response": result.final_response,
|
||||
@@ -223,15 +194,10 @@ def test_sync_goal_run_aggregates_automatic_continuation(tmp_path) -> None:
|
||||
if result.started_at is not None and result.completed_at is not None
|
||||
else False,
|
||||
),
|
||||
"initial_input": requests[0].message_input_texts("user")[-2:],
|
||||
"model": first_body["model"],
|
||||
"output_schema": first_body["text"]["format"]["schema"],
|
||||
"continuation_has_objective": (
|
||||
"<objective>\nImprove benchmark coverage\n\nDocument the results\n</objective>"
|
||||
in _continuation_text(requests[1])
|
||||
),
|
||||
"continuation_has_objective": "<objective>\nImprove benchmark coverage\n</objective>"
|
||||
in _continuation_text(requests[0]),
|
||||
} == {
|
||||
"id": turn.id,
|
||||
"id_is_present": True,
|
||||
"status": TurnStatus.completed,
|
||||
"messages": ["Initial pass complete.", "Goal complete."],
|
||||
"final_response": "Goal complete.",
|
||||
@@ -253,14 +219,6 @@ def test_sync_goal_run_aggregates_automatic_continuation(tmp_path) -> None:
|
||||
},
|
||||
},
|
||||
"timing": (True, True, True),
|
||||
"initial_input": [" Improve benchmark coverage ", "Document the results"],
|
||||
"model": "goal-test-model",
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {"summary": {"type": "string"}},
|
||||
"required": ["summary"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
"continuation_has_objective": True,
|
||||
}
|
||||
|
||||
@@ -303,8 +261,8 @@ def test_goal_stream_exposes_one_logical_lifecycle(tmp_path) -> None:
|
||||
)
|
||||
)
|
||||
|
||||
with Codex(config=_source_config(harness)) as codex:
|
||||
turn = codex.thread_start().turn("Finish the integration suite", goal=True)
|
||||
with Codex(config=harness.app_server_config()) as codex:
|
||||
turn = codex.thread_start().start_goal("Finish the integration suite")
|
||||
events = list(turn.stream())
|
||||
|
||||
lifecycle = [event for event in events if event.method in {"turn/started", "turn/completed"}]
|
||||
@@ -362,8 +320,8 @@ def test_goal_can_complete_within_the_initial_server_turn(tmp_path) -> None:
|
||||
response_id="single-turn-final",
|
||||
)
|
||||
|
||||
with Codex(config=_source_config(harness)) as codex:
|
||||
turn = codex.thread_start().turn("Finish in the initial turn", goal=True)
|
||||
with Codex(config=harness.app_server_config()) as codex:
|
||||
turn = codex.thread_start().start_goal("Finish in the initial turn")
|
||||
result = turn.run()
|
||||
requests = harness.responses.wait_for_requests(2)
|
||||
with pytest.raises(InvalidRequestError) as steer_error:
|
||||
@@ -394,19 +352,14 @@ def test_goal_replaces_an_existing_persisted_goal(tmp_path) -> None:
|
||||
final_text="Replacement complete.",
|
||||
)
|
||||
|
||||
with Codex(config=_source_config(harness)) as codex:
|
||||
with Codex(config=harness.app_server_config()) as codex:
|
||||
thread = codex.thread_start()
|
||||
previous = codex._client.request(
|
||||
"thread/goal/set",
|
||||
{
|
||||
"threadId": thread.id,
|
||||
"objective": "Keep the old benchmark objective",
|
||||
"status": ThreadGoalStatus.paused.value,
|
||||
"tokenBudget": 500,
|
||||
},
|
||||
response_model=ThreadGoalSetResponse,
|
||||
previous = codex._client.thread_goal_set(
|
||||
thread.id,
|
||||
objective="Keep the old benchmark objective",
|
||||
status=ThreadGoalStatus.paused,
|
||||
).goal
|
||||
result = thread.run("Publish the replacement objective", goal=True)
|
||||
result = thread.run_goal("Publish the replacement objective")
|
||||
persisted = codex._client.request(
|
||||
"thread/goal/get",
|
||||
{"threadId": thread.id},
|
||||
@@ -415,7 +368,7 @@ def test_goal_replaces_an_existing_persisted_goal(tmp_path) -> None:
|
||||
requests = harness.responses.wait_for_requests(3)
|
||||
|
||||
assert {
|
||||
"previous": (previous.objective, previous.status, previous.token_budget),
|
||||
"previous": (previous.objective, previous.status),
|
||||
"result": (result.status, result.final_response),
|
||||
"persisted": (
|
||||
persisted.objective if persisted else None,
|
||||
@@ -423,17 +376,13 @@ def test_goal_replaces_an_existing_persisted_goal(tmp_path) -> None:
|
||||
persisted.token_budget if persisted else None,
|
||||
),
|
||||
"continuation_has_replacement": (
|
||||
"Publish the replacement objective" in _continuation_text(requests[1])
|
||||
"Publish the replacement objective" in _continuation_text(requests[0])
|
||||
),
|
||||
"continuation_has_previous": (
|
||||
"Keep the old benchmark objective" in _continuation_text(requests[1])
|
||||
"Keep the old benchmark objective" in _continuation_text(requests[0])
|
||||
),
|
||||
} == {
|
||||
"previous": (
|
||||
"Keep the old benchmark objective",
|
||||
ThreadGoalStatus.paused,
|
||||
500,
|
||||
),
|
||||
"previous": ("Keep the old benchmark objective", ThreadGoalStatus.paused),
|
||||
"result": (TurnStatus.completed, "Replacement complete."),
|
||||
"persisted": (
|
||||
"Publish the replacement objective",
|
||||
@@ -456,8 +405,8 @@ def test_goal_steer_targets_an_active_continuation(tmp_path) -> None:
|
||||
final_text="Steered goal complete.",
|
||||
)
|
||||
|
||||
with Codex(config=_source_config(harness)) as codex:
|
||||
turn = codex.thread_start().turn("Start a goal that needs refinement", goal=True)
|
||||
with Codex(config=harness.app_server_config()) as codex:
|
||||
turn = codex.thread_start().start_goal("Start a goal that needs refinement")
|
||||
harness.responses.wait_for_requests(2)
|
||||
steer = turn.steer("Prioritize the edge-case coverage.")
|
||||
result = turn.run()
|
||||
@@ -491,9 +440,9 @@ def test_goal_interrupt_pauses_continuation_and_leaves_thread_usable(tmp_path) -
|
||||
follow_up_text="Ordinary follow-up complete.",
|
||||
)
|
||||
|
||||
with Codex(config=_source_config(harness)) as codex:
|
||||
with Codex(config=harness.app_server_config()) as codex:
|
||||
thread = codex.thread_start()
|
||||
turn = thread.turn("Start interruptible goal work", goal=True)
|
||||
turn = thread.start_goal("Start interruptible goal work")
|
||||
harness.responses.wait_for_requests(2)
|
||||
interrupt = turn.interrupt()
|
||||
interrupted = turn.run()
|
||||
@@ -532,10 +481,10 @@ def test_terminal_goal_failure_stops_continuation_and_releases_routing(tmp_path)
|
||||
response_id="goal-failure-follow-up",
|
||||
)
|
||||
|
||||
with Codex(config=_source_config(harness)) as codex:
|
||||
with Codex(config=harness.app_server_config()) as codex:
|
||||
thread = codex.thread_start()
|
||||
with pytest.raises(RuntimeError, match="goal model failed"):
|
||||
thread.run("Fail this goal turn", goal=True)
|
||||
thread.run_goal("Fail this goal turn")
|
||||
|
||||
follow_up = thread.run("Run after the goal failure")
|
||||
harness.responses.wait_for_requests(2)
|
||||
@@ -553,7 +502,7 @@ def test_terminal_goal_failure_stops_continuation_and_releases_routing(tmp_path)
|
||||
|
||||
|
||||
def test_closing_goal_stream_releases_real_process_routing(tmp_path) -> None:
|
||||
"""Closing a public stream should immediately unregister its logical operation."""
|
||||
"""Closing a stream should release SDK routing without pausing the persisted goal."""
|
||||
with AppServerHarness(tmp_path, enable_goals=True) as harness:
|
||||
harness.responses.enqueue_sse(
|
||||
streaming_response(
|
||||
@@ -564,14 +513,26 @@ def test_closing_goal_stream_releases_real_process_routing(tmp_path) -> None:
|
||||
delay_between_events_s=0.5,
|
||||
)
|
||||
|
||||
with Codex(config=_source_config(harness)) as codex:
|
||||
turn = codex.thread_start().turn("Close this goal stream", goal=True)
|
||||
with Codex(config=harness.app_server_config()) as codex:
|
||||
thread = codex.thread_start()
|
||||
turn = thread.start_goal("Close this goal stream")
|
||||
harness.responses.wait_for_requests(1)
|
||||
stream = turn.stream()
|
||||
stream.close()
|
||||
registered_goals = dict(codex._client._router._goal_operations)
|
||||
persisted = codex._client.request(
|
||||
"thread/goal/get",
|
||||
{"threadId": thread.id},
|
||||
response_model=ThreadGoalGetResponse,
|
||||
).goal
|
||||
|
||||
assert registered_goals == {}
|
||||
assert {
|
||||
"registered_goals": registered_goals,
|
||||
"persisted_status": persisted.status if persisted else None,
|
||||
} == {
|
||||
"registered_goals": {},
|
||||
"persisted_status": ThreadGoalStatus.active,
|
||||
}
|
||||
|
||||
|
||||
def test_app_server_exit_unblocks_goal_stream_and_releases_routing(tmp_path) -> None:
|
||||
@@ -586,8 +547,8 @@ def test_app_server_exit_unblocks_goal_stream_and_releases_routing(tmp_path) ->
|
||||
delay_between_events_s=0.5,
|
||||
)
|
||||
|
||||
with Codex(config=_source_config(harness)) as codex:
|
||||
turn = codex.thread_start().turn("Stop the app-server during this goal", goal=True)
|
||||
with Codex(config=harness.app_server_config()) as codex:
|
||||
turn = codex.thread_start().start_goal("Stop the app-server during this goal")
|
||||
harness.responses.wait_for_requests(1)
|
||||
process = codex._client._proc
|
||||
assert process is not None
|
||||
@@ -609,29 +570,89 @@ def test_failed_goal_starts_release_routing_without_model_requests(tmp_path) ->
|
||||
response_id="validation-follow-up",
|
||||
)
|
||||
|
||||
with Codex(config=_source_config(harness)) as codex:
|
||||
with Codex(config=harness.app_server_config()) as codex:
|
||||
thread = codex.thread_start()
|
||||
with pytest.raises(InvalidRequestError) as empty_error:
|
||||
thread.turn(" ", goal=True)
|
||||
with pytest.raises(ValueError) as empty_error:
|
||||
thread.start_goal(" ")
|
||||
with pytest.raises(TypeError) as type_error:
|
||||
thread.start_goal(123) # type: ignore[arg-type]
|
||||
|
||||
ephemeral = codex.thread_start(ephemeral=True)
|
||||
with pytest.raises(InvalidRequestError) as ephemeral_error:
|
||||
ephemeral.turn("Persist this goal", goal=True)
|
||||
ephemeral.start_goal("Persist this goal")
|
||||
|
||||
follow_up = thread.run("Run after rejected goals")
|
||||
requests = harness.responses.wait_for_requests(1)
|
||||
registered_goals = dict(codex._client._router._goal_operations)
|
||||
|
||||
assert {
|
||||
"errors": [empty_error.value.message, ephemeral_error.value.message],
|
||||
"errors": [str(empty_error.value), str(type_error.value), ephemeral_error.value.message],
|
||||
"follow_up": (follow_up.status, follow_up.final_response),
|
||||
"request_count": len(requests),
|
||||
"registered_goals": registered_goals,
|
||||
} == {
|
||||
"errors": [
|
||||
"goal objective must not be empty",
|
||||
f"ephemeral thread does not support goals: {ephemeral.id}",
|
||||
"goal objective must be a string",
|
||||
f"thread must be persisted before starting a goal: {ephemeral.id}",
|
||||
],
|
||||
"follow_up": (TurnStatus.completed, "Ordinary turn complete."),
|
||||
"request_count": 1,
|
||||
"registered_goals": {},
|
||||
}
|
||||
|
||||
|
||||
def test_disabled_goals_fail_before_model_work_or_routing(tmp_path) -> None:
|
||||
"""A runtime with goals disabled should reject startup without leaking state."""
|
||||
with AppServerHarness(tmp_path) as harness:
|
||||
with Codex(config=harness.app_server_config()) as codex:
|
||||
thread = codex.thread_start()
|
||||
with pytest.raises(InvalidRequestError) as error:
|
||||
thread.start_goal("This goal must not start")
|
||||
registered_goals = dict(codex._client._router._goal_operations)
|
||||
requests = harness.responses.requests()
|
||||
|
||||
assert {
|
||||
"error": error.value.message,
|
||||
"registered_goals": registered_goals,
|
||||
"request_count": len(requests),
|
||||
} == {
|
||||
"error": "goals feature is disabled",
|
||||
"registered_goals": {},
|
||||
"request_count": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_active_thread_rejects_goal_start_and_keeps_ordinary_turn_usable(tmp_path) -> None:
|
||||
"""Goal startup should require an idle thread without disturbing active work."""
|
||||
with AppServerHarness(tmp_path, enable_goals=True) as harness:
|
||||
harness.responses.enqueue_sse(
|
||||
streaming_response(
|
||||
"active-ordinary-turn",
|
||||
"msg-active-ordinary-turn",
|
||||
["ordinary ", "work"],
|
||||
),
|
||||
delay_between_events_s=0.5,
|
||||
)
|
||||
|
||||
with Codex(config=harness.app_server_config()) as codex:
|
||||
thread = codex.thread_start()
|
||||
ordinary = thread.turn("Keep this ordinary turn active")
|
||||
harness.responses.wait_for_requests(1)
|
||||
with pytest.raises(InvalidRequestError) as error:
|
||||
thread.start_goal("Do not replace active work")
|
||||
ordinary.interrupt()
|
||||
result = ordinary.run()
|
||||
registered_goals = dict(codex._client._router._goal_operations)
|
||||
|
||||
assert {
|
||||
"error": error.value.message,
|
||||
"ordinary_result": (result.id, result.status),
|
||||
"registered_goals": registered_goals,
|
||||
} == {
|
||||
"error": f"thread must be idle before starting a goal: {thread.id}",
|
||||
"ordinary_result": (ordinary.id, TurnStatus.interrupted),
|
||||
"registered_goals": {},
|
||||
}
|
||||
|
||||
|
||||
@@ -647,30 +668,23 @@ def test_async_goal_run_matches_sync_logical_result(tmp_path) -> None:
|
||||
final_text="Async goal complete.",
|
||||
)
|
||||
|
||||
async with AsyncCodex(config=_source_config(harness)) as codex:
|
||||
async with AsyncCodex(config=harness.app_server_config()) as codex:
|
||||
thread = await codex.thread_start()
|
||||
turn = await thread.turn("Finish the async goal", goal=True)
|
||||
result = await turn.run()
|
||||
result = await thread.run_goal("Finish the async goal")
|
||||
requests = harness.responses.wait_for_requests(3)
|
||||
with pytest.raises(InvalidRequestError) as steer_error:
|
||||
await turn.steer("Keep working")
|
||||
with pytest.raises(InvalidRequestError) as interrupt_error:
|
||||
await turn.interrupt()
|
||||
|
||||
assert {
|
||||
"status": result.status,
|
||||
"messages": agent_message_texts_from_items(result.items),
|
||||
"final_response": result.final_response,
|
||||
"continuation_has_objective": (
|
||||
"Finish the async goal" in _continuation_text(requests[1])
|
||||
"Finish the async goal" in _continuation_text(requests[0])
|
||||
),
|
||||
"inactive_errors": [steer_error.value.message, interrupt_error.value.message],
|
||||
} == {
|
||||
"status": TurnStatus.completed,
|
||||
"messages": ["Async initial pass.", "Async goal complete."],
|
||||
"final_response": "Async goal complete.",
|
||||
"continuation_has_objective": True,
|
||||
"inactive_errors": ["no active turn to steer", "no active turn to interrupt"],
|
||||
}
|
||||
|
||||
asyncio.run(scenario())
|
||||
@@ -689,12 +703,9 @@ def test_async_goal_steer_targets_an_active_continuation(tmp_path) -> None:
|
||||
final_text="Async steered goal complete.",
|
||||
)
|
||||
|
||||
async with AsyncCodex(config=_source_config(harness)) as codex:
|
||||
async with AsyncCodex(config=harness.app_server_config()) as codex:
|
||||
thread = await codex.thread_start()
|
||||
turn = await thread.turn(
|
||||
"Start an async goal that needs refinement",
|
||||
goal=True,
|
||||
)
|
||||
turn = await thread.start_goal("Start an async goal that needs refinement")
|
||||
await asyncio.to_thread(harness.responses.wait_for_requests, 2)
|
||||
steer = await turn.steer("Prioritize async edge-case coverage.")
|
||||
result = await turn.run()
|
||||
@@ -732,9 +743,9 @@ def test_async_goal_interrupts_an_active_continuation(tmp_path) -> None:
|
||||
follow_up_text="Async ordinary follow-up complete.",
|
||||
)
|
||||
|
||||
async with AsyncCodex(config=_source_config(harness)) as codex:
|
||||
async with AsyncCodex(config=harness.app_server_config()) as codex:
|
||||
thread = await codex.thread_start()
|
||||
turn = await thread.turn("Start async interruptible goal work", goal=True)
|
||||
turn = await thread.start_goal("Start async interruptible goal work")
|
||||
await asyncio.to_thread(harness.responses.wait_for_requests, 2)
|
||||
interrupt = await turn.interrupt()
|
||||
interrupted = await turn.run()
|
||||
|
||||
@@ -185,17 +185,29 @@ def test_turn_input_methods_accept_string_shortcut() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_turn_result_producers_expose_goal_mode() -> None:
|
||||
"""Goal mode belongs to APIs that produce a turn result or handle."""
|
||||
funcs = [Thread.run, Thread.turn, AsyncThread.run, AsyncThread.turn]
|
||||
def test_dedicated_goal_operations_have_pythonic_signatures() -> None:
|
||||
"""Goal operations should accept only an objective and return existing turn types."""
|
||||
goal_methods = {
|
||||
Thread.run_goal: "TurnResult",
|
||||
Thread.start_goal: "TurnHandle",
|
||||
AsyncThread.run_goal: "TurnResult",
|
||||
AsyncThread.start_goal: "AsyncTurnHandle",
|
||||
}
|
||||
ordinary_methods = [Thread.run, Thread.turn, AsyncThread.run, AsyncThread.turn]
|
||||
|
||||
assert {
|
||||
fn: (
|
||||
inspect.signature(fn).parameters["goal"].annotation,
|
||||
inspect.signature(fn).parameters["goal"].default,
|
||||
list(inspect.signature(fn).parameters),
|
||||
inspect.signature(fn).parameters["objective"].annotation,
|
||||
inspect.signature(fn).return_annotation,
|
||||
)
|
||||
for fn in funcs
|
||||
} == dict.fromkeys(funcs, ("bool", False))
|
||||
for fn in goal_methods
|
||||
} == {
|
||||
fn: (["self", "objective"], "str", return_type) for fn, return_type in goal_methods.items()
|
||||
}
|
||||
assert {fn: "goal" in inspect.signature(fn).parameters for fn in ordinary_methods} == (
|
||||
dict.fromkeys(ordinary_methods, False)
|
||||
)
|
||||
|
||||
|
||||
def test_root_exports_approval_mode() -> None:
|
||||
@@ -241,6 +253,10 @@ def test_curated_public_api_has_builtin_help_documentation() -> None:
|
||||
"thread_resume": Codex.thread_resume,
|
||||
"thread_run": Thread.run,
|
||||
"thread_turn": Thread.turn,
|
||||
"thread_run_goal": Thread.run_goal,
|
||||
"thread_start_goal": Thread.start_goal,
|
||||
"async_thread_run_goal": AsyncThread.run_goal,
|
||||
"async_thread_start_goal": AsyncThread.start_goal,
|
||||
}
|
||||
|
||||
assert {name: inspect.getdoc(value) is not None for name, value in documented.items()} == (
|
||||
@@ -383,7 +399,6 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
|
||||
"thread_source",
|
||||
],
|
||||
Thread.turn: [
|
||||
"goal",
|
||||
"approval_mode",
|
||||
"cwd",
|
||||
"effort",
|
||||
@@ -395,7 +410,6 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
|
||||
"summary",
|
||||
],
|
||||
Thread.run: [
|
||||
"goal",
|
||||
"approval_mode",
|
||||
"cwd",
|
||||
"effort",
|
||||
@@ -460,7 +474,6 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
|
||||
"thread_source",
|
||||
],
|
||||
AsyncThread.turn: [
|
||||
"goal",
|
||||
"approval_mode",
|
||||
"cwd",
|
||||
"effort",
|
||||
@@ -472,7 +485,6 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
|
||||
"summary",
|
||||
],
|
||||
AsyncThread.run: [
|
||||
"goal",
|
||||
"approval_mode",
|
||||
"cwd",
|
||||
"effort",
|
||||
|
||||
Reference in New Issue
Block a user