From f0166cadbb07c79c193b0c0bcdff4b52a836d111 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Sun, 17 May 2026 06:17:22 -0700 Subject: [PATCH 1/6] [codex] Return TurnResult from Python turn handles (#23151) ## Why `TurnHandle.run()` returned the raw app-server `Turn`, whose live start/completed payloads do not include loaded `items`, so users saw empty `items` after starting a turn. That made the handle-based path behave differently from `Thread.run(...)`, and pushed examples toward persisted-thread reads plus helper extraction. This PR makes the run APIs standalone: starting a turn and running it returns collected turn data directly, or fails visibly when required stream events are missing. ## What Changed - Replaces the public `RunResult` export with `TurnResult`. - Adds turn metadata to `TurnResult`: `id`, `status`, `error`, `started_at`, `completed_at`, and `duration_ms`, alongside `final_response`, `items`, and `usage`. - Changes `TurnHandle.run()` and `AsyncTurnHandle.run()` to consume stream events with the same collector used by `Thread.run(...)`. - Exports `TurnError` from `openai_codex.types` for the new result shape. - Updates tests, examples, docs, and the walkthrough notebook to use `result.final_response` and `result.items` directly. - Removes persisted-thread helper paths and placeholder/skipped control flows from the public examples and notebook. ## Verification - `python3 -m py_compile ...` over changed SDK, example, and test Python files. - `python3 -c "import json; json.load(open('sdk/python/notebooks/sdk_walkthrough.ipynb'))"` - `git diff --check` - `PYTHONPATH=sdk/python/src python3 -c ...` import/signature smoke for `TurnResult`, `TurnHandle.run`, and `AsyncTurnHandle.run`. --- sdk/python/README.md | 5 +- sdk/python/docs/api-reference.md | 19 +- sdk/python/docs/faq.md | 9 +- sdk/python/docs/getting-started.md | 4 +- sdk/python/examples/02_turn_run/async.py | 16 +- sdk/python/examples/02_turn_run/sync.py | 16 +- .../examples/03_turn_stream_events/async.py | 27 ++- .../examples/03_turn_stream_events/sync.py | 27 ++- .../examples/04_models_and_metadata/async.py | 2 +- .../examples/04_models_and_metadata/sync.py | 2 +- .../examples/05_existing_thread/async.py | 11 +- .../examples/05_existing_thread/sync.py | 11 +- .../06_thread_lifecycle_and_controls/async.py | 48 ++-- .../06_thread_lifecycle_and_controls/sync.py | 42 ++-- .../examples/07_image_and_text/async.py | 11 +- sdk/python/examples/07_image_and_text/sync.py | 11 +- .../examples/08_local_image_and_text/async.py | 6 +- .../examples/08_local_image_and_text/sync.py | 6 +- sdk/python/examples/09_async_parity/sync.py | 12 +- .../10_error_handling_and_retry/async.py | 14 +- .../10_error_handling_and_retry/sync.py | 14 +- sdk/python/examples/11_cli_mini_app/async.py | 31 +-- sdk/python/examples/11_cli_mini_app/sync.py | 31 +-- .../12_turn_params_kitchen_sink/async.py | 17 +- .../12_turn_params_kitchen_sink/sync.py | 17 +- .../13_model_select_and_turn_params/async.py | 44 ++-- .../13_model_select_and_turn_params/sync.py | 39 ++-- sdk/python/examples/14_turn_controls/async.py | 58 ++--- sdk/python/examples/14_turn_controls/sync.py | 56 ++--- sdk/python/examples/README.md | 2 +- sdk/python/examples/_bootstrap.py | 54 +---- sdk/python/notebooks/sdk_walkthrough.ipynb | 212 ++++++------------ sdk/python/src/openai_codex/__init__.py | 4 +- sdk/python/src/openai_codex/_run.py | 33 ++- sdk/python/src/openai_codex/api.py | 39 +--- sdk/python/src/openai_codex/types.py | 2 + sdk/python/tests/app_server_helpers.py | 2 +- sdk/python/tests/test_app_server_lifecycle.py | 12 +- sdk/python/tests/test_app_server_run.py | 22 +- sdk/python/tests/test_app_server_streaming.py | 9 +- .../tests/test_public_api_signatures.py | 43 +++- .../tests/test_real_app_server_integration.py | 36 ++- 42 files changed, 399 insertions(+), 677 deletions(-) diff --git a/sdk/python/README.md b/sdk/python/README.md index a04d59b239..9107d15ba9 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -34,8 +34,9 @@ with Codex() as codex: print(len(result.items)) ``` -`result.final_response` is `None` when the turn completes without a final-answer -or phase-less assistant message item. +`thread.run(...)` and `thread.turn(...).run()` return `TurnResult`. Its +`final_response` is `None` when the turn completes without a final-answer or +phase-less assistant message item. ## Login diff --git a/sdk/python/docs/api-reference.md b/sdk/python/docs/api-reference.md index 1b885b8c89..4bee871f8c 100644 --- a/sdk/python/docs/api-reference.md +++ b/sdk/python/docs/api-reference.md @@ -16,11 +16,11 @@ from openai_codex import ( DeviceCodeLoginHandle, AsyncChatgptLoginHandle, AsyncDeviceCodeLoginHandle, - RunResult, Thread, AsyncThread, TurnHandle, AsyncTurnHandle, + TurnResult, Input, InputItem, TextInput, @@ -38,6 +38,7 @@ from openai_codex.types import ( InitializeResponse, ThreadItem, ThreadTokenUsage, + TurnError, TurnStatus, ) ``` @@ -146,7 +147,7 @@ attempt. API-key login completes synchronously and does not return a handle. ### Thread -- `run(input: str | Input, *, approval_mode=ApprovalMode.auto_review, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> RunResult` +- `run(input: str | Input, *, approval_mode=ApprovalMode.auto_review, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> TurnResult` - `turn(input: Input, *, approval_mode=ApprovalMode.auto_review, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, summary=None) -> TurnHandle` - `read(*, include_turns: bool = False) -> ThreadReadResponse` - `set_name(name: str) -> ThreadSetNameResponse` @@ -154,7 +155,7 @@ attempt. API-key login completes synchronously and does not return a handle. ### AsyncThread -- `run(input: str | Input, *, approval_mode=ApprovalMode.auto_review, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> Awaitable[RunResult]` +- `run(input: str | Input, *, approval_mode=ApprovalMode.auto_review, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> Awaitable[TurnResult]` - `turn(input: Input, *, approval_mode=ApprovalMode.auto_review, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, summary=None) -> Awaitable[AsyncTurnHandle]` - `read(*, include_turns: bool = False) -> Awaitable[ThreadReadResponse]` - `set_name(name: str) -> Awaitable[ThreadSetNameResponse]` @@ -164,6 +165,12 @@ attempt. API-key login completes synchronously and does not return a handle. the turn, consumes notifications until completion, and returns a small result object with: +- `id: str` +- `status: TurnStatus` +- `error: TurnError | None` +- `started_at: int | None` +- `completed_at: int | None` +- `duration_ms: int | None` - `final_response: str | None` - `items: list[ThreadItem]` - `usage: ThreadTokenUsage | None` @@ -172,7 +179,7 @@ object with: phase-less assistant message item. Use `turn(...)` when you need low-level turn control (`stream()`, `steer()`, -`interrupt()`) or the public `Turn` model from `TurnHandle.run()`. +`interrupt()`) before collecting the turn result. ## TurnHandle / AsyncTurnHandle @@ -181,7 +188,7 @@ Use `turn(...)` when you need low-level turn control (`stream()`, `steer()`, - `steer(input: Input) -> TurnSteerResponse` - `interrupt() -> TurnInterruptResponse` - `stream() -> Iterator[Notification]` -- `run() -> openai_codex.types.Turn` +- `run() -> TurnResult` Behavior notes: @@ -193,7 +200,7 @@ Behavior notes: - `steer(input: Input) -> Awaitable[TurnSteerResponse]` - `interrupt() -> Awaitable[TurnInterruptResponse]` - `stream() -> AsyncIterator[Notification]` -- `run() -> Awaitable[openai_codex.types.Turn]` +- `run() -> Awaitable[TurnResult]` Behavior notes: diff --git a/sdk/python/docs/faq.md b/sdk/python/docs/faq.md index d1732e74aa..5f2ed87ffd 100644 --- a/sdk/python/docs/faq.md +++ b/sdk/python/docs/faq.md @@ -8,7 +8,8 @@ ## `run()` vs `stream()` -- `TurnHandle.run()` / `AsyncTurnHandle.run()` is the easiest path. It consumes events until completion and returns the public app-server `Turn` model from `openai_codex.types`. +- `Thread.run(...)` starts a turn and returns `TurnResult`. +- `TurnHandle.run()` / `AsyncTurnHandle.run()` consumes events for an existing turn handle and returns the same `TurnResult` shape. - `TurnHandle.stream()` / `AsyncTurnHandle.stream()` yields raw notifications (`Notification`) so you can react event-by-event. Choose `run()` for most apps. Choose `stream()` for progress UIs, custom timeout logic, or custom parsing. @@ -66,7 +67,7 @@ Common causes: - published runtime package (`openai-codex-cli-bin`) is not installed - local `codex_bin` override points to a missing file -- incompatible/old app-server +- app-server version older than the SDK schema ## Why does a turn "hang"? @@ -79,11 +80,11 @@ A turn is complete only when `turn/completed` arrives for that turn ID. Use `retry_on_overload(...)` for transient overload failures (`ServerBusyError`). -Do not blindly retry all errors. For `InvalidParamsError` or `MethodNotFoundError`, fix inputs/version compatibility instead. +Do not blindly retry all errors. For `InvalidParamsError` or `MethodNotFoundError`, fix inputs or update the runtime/schema version instead. ## Common pitfalls - Starting a new thread for every prompt when you wanted continuity. - Forgetting to `close()` (or not using context managers). -- Assuming `run()` returns extra SDK-only fields instead of the public `Turn` model. +- Reading `Turn.items` from live start/completed payloads instead of using `TurnResult.items`. - Mixing SDK input classes with raw dicts incorrectly. diff --git a/sdk/python/docs/getting-started.md b/sdk/python/docs/getting-started.md index b362a90347..1df0026c68 100644 --- a/sdk/python/docs/getting-started.md +++ b/sdk/python/docs/getting-started.md @@ -70,9 +70,9 @@ What happened: - `Codex()` started and initialized `codex app-server`. - `thread_start(...)` created a thread. -- `thread.run("...")` started a turn, consumed events until completion, and returned the final assistant response plus collected items and usage. +- `thread.run("...")` started a turn, consumed events until completion, and returned `TurnResult` with turn metadata, final assistant response, collected items, and usage. - `result.final_response` is `None` when no final-answer or phase-less assistant message item completes for the turn. -- use `thread.turn(...)` when you need a `TurnHandle` for streaming, steering, interrupting, or turn IDs/status +- use `thread.turn(...)` when you need a `TurnHandle` for streaming, steering, or interrupting before collecting `TurnResult` - one client can consume multiple active turns concurrently; turn streams are routed by turn ID ## 4) Continue the same thread (multi-turn) diff --git a/sdk/python/examples/02_turn_run/async.py b/sdk/python/examples/02_turn_run/async.py index 0f6ef94f91..debfad8d6e 100644 --- a/sdk/python/examples/02_turn_run/async.py +++ b/sdk/python/examples/02_turn_run/async.py @@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -26,19 +21,14 @@ async def main() -> None: ) turn = await thread.turn(TextInput("Give 3 bullets about SIMD.")) result = await turn.run() - persisted = await thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) print("thread_id:", thread.id) print("turn_id:", result.id) print("status:", result.status) if result.error is not None: print("error:", result.error) - print("text:", assistant_text_from_turn(persisted_turn)) - print( - "persisted.items.count:", - 0 if persisted_turn is None else len(persisted_turn.items or []), - ) + print("text:", result.final_response) + print("items.count:", len(result.items)) if __name__ == "__main__": diff --git a/sdk/python/examples/02_turn_run/sync.py b/sdk/python/examples/02_turn_run/sync.py index 18aa8c2f81..c42bffa1f2 100644 --- a/sdk/python/examples/02_turn_run/sync.py +++ b/sdk/python/examples/02_turn_run/sync.py @@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -19,16 +14,11 @@ from openai_codex import Codex, TextInput with Codex(config=runtime_config()) as codex: thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) result = thread.turn(TextInput("Give 3 bullets about SIMD.")).run() - persisted = thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) print("thread_id:", thread.id) print("turn_id:", result.id) print("status:", result.status) if result.error is not None: print("error:", result.error) - print("text:", assistant_text_from_turn(persisted_turn)) - print( - "persisted.items.count:", - 0 if persisted_turn is None else len(persisted_turn.items or []), - ) + print("text:", result.final_response) + print("items.count:", len(result.items)) diff --git a/sdk/python/examples/03_turn_stream_events/async.py b/sdk/python/examples/03_turn_stream_events/async.py index dcf57af4f2..f0d3f1427d 100644 --- a/sdk/python/examples/03_turn_stream_events/async.py +++ b/sdk/python/examples/03_turn_stream_events/async.py @@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -29,7 +24,8 @@ async def main() -> None: event_count = 0 saw_started = False saw_delta = False - completed_status = "unknown" + completed_status = None + completed_texts = [] async for event in turn.stream(): event_count += 1 @@ -38,24 +34,27 @@ async def main() -> None: print("stream.started") continue if event.method == "item/agentMessage/delta": - delta = getattr(event.payload, "delta", "") + delta = event.payload.delta if delta: if not saw_delta: print("assistant> ", end="", flush=True) print(delta, end="", flush=True) saw_delta = True continue + if event.method == "item/completed": + root = event.payload.item.root + if root.type == "agentMessage": + completed_texts.append(root.text) + continue if event.method == "turn/completed": - completed_status = getattr( - event.payload.turn.status, "value", str(event.payload.turn.status) - ) + completed_status = event.payload.turn.status.value + if completed_status is None: + raise RuntimeError("stream ended without turn/completed") if saw_delta: print() else: - persisted = await thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, turn.id) - final_text = assistant_text_from_turn(persisted_turn).strip() or "[no assistant text]" + final_text = "".join(completed_texts).strip() print("assistant>", final_text) print("stream.started.seen:", saw_started) diff --git a/sdk/python/examples/03_turn_stream_events/sync.py b/sdk/python/examples/03_turn_stream_events/sync.py index f96f5c6acd..5a76bcb70f 100644 --- a/sdk/python/examples/03_turn_stream_events/sync.py +++ b/sdk/python/examples/03_turn_stream_events/sync.py @@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -23,7 +18,8 @@ with Codex(config=runtime_config()) as codex: event_count = 0 saw_started = False saw_delta = False - completed_status = "unknown" + completed_status = None + completed_texts = [] for event in turn.stream(): event_count += 1 @@ -32,24 +28,27 @@ with Codex(config=runtime_config()) as codex: print("stream.started") continue if event.method == "item/agentMessage/delta": - delta = getattr(event.payload, "delta", "") + delta = event.payload.delta if delta: if not saw_delta: print("assistant> ", end="", flush=True) print(delta, end="", flush=True) saw_delta = True continue + if event.method == "item/completed": + root = event.payload.item.root + if root.type == "agentMessage": + completed_texts.append(root.text) + continue if event.method == "turn/completed": - completed_status = getattr( - event.payload.turn.status, "value", str(event.payload.turn.status) - ) + completed_status = event.payload.turn.status.value + if completed_status is None: + raise RuntimeError("stream ended without turn/completed") if saw_delta: print() else: - persisted = thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, turn.id) - final_text = assistant_text_from_turn(persisted_turn).strip() or "[no assistant text]" + final_text = "".join(completed_texts).strip() print("assistant>", final_text) print("stream.started.seen:", saw_started) diff --git a/sdk/python/examples/04_models_and_metadata/async.py b/sdk/python/examples/04_models_and_metadata/async.py index ba36e51a23..23ca58131f 100644 --- a/sdk/python/examples/04_models_and_metadata/async.py +++ b/sdk/python/examples/04_models_and_metadata/async.py @@ -19,7 +19,7 @@ async def main() -> None: print("server:", server_label(codex.metadata)) models = await codex.models() print("models.count:", len(models.data)) - print("models:", ", ".join(model.id for model in models.data[:5]) or "[none]") + print("models:", ", ".join(model.id for model in models.data[:5])) if __name__ == "__main__": diff --git a/sdk/python/examples/04_models_and_metadata/sync.py b/sdk/python/examples/04_models_and_metadata/sync.py index 849116941f..155e53571d 100644 --- a/sdk/python/examples/04_models_and_metadata/sync.py +++ b/sdk/python/examples/04_models_and_metadata/sync.py @@ -15,4 +15,4 @@ with Codex(config=runtime_config()) as codex: print("server:", server_label(codex.metadata)) models = codex.models() print("models.count:", len(models.data)) - print("models:", ", ".join(model.id for model in models.data[:5]) or "[none]") + print("models:", ", ".join(model.id for model in models.data[:5])) diff --git a/sdk/python/examples/05_existing_thread/async.py b/sdk/python/examples/05_existing_thread/async.py index 4dc36531d8..df3946ac87 100644 --- a/sdk/python/examples/05_existing_thread/async.py +++ b/sdk/python/examples/05_existing_thread/async.py @@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -32,9 +27,7 @@ async def main() -> None: resumed = await codex.thread_resume(original.id) second_turn = await resumed.turn(TextInput("Continue with one more fact.")) second = await second_turn.run() - persisted = await resumed.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, second.id) - print(assistant_text_from_turn(persisted_turn)) + print(second.final_response) if __name__ == "__main__": diff --git a/sdk/python/examples/05_existing_thread/sync.py b/sdk/python/examples/05_existing_thread/sync.py index ca441c4223..a0f0d37e27 100644 --- a/sdk/python/examples/05_existing_thread/sync.py +++ b/sdk/python/examples/05_existing_thread/sync.py @@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -25,6 +20,4 @@ with Codex(config=runtime_config()) as codex: # Resume the existing thread by ID. resumed = codex.thread_resume(original.id) second = resumed.turn(TextInput("Continue with one more fact.")).run() - persisted = resumed.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, second.id) - print(assistant_text_from_turn(persisted_turn)) + print(second.final_response) diff --git a/sdk/python/examples/06_thread_lifecycle_and_controls/async.py b/sdk/python/examples/06_thread_lifecycle_and_controls/async.py index ad73a896ee..24afbde6c2 100644 --- a/sdk/python/examples/06_thread_lifecycle_and_controls/async.py +++ b/sdk/python/examples/06_thread_lifecycle_and_controls/async.py @@ -33,45 +33,31 @@ async def main() -> None: listing_archived = await codex.thread_list(limit=20, archived=True) unarchived = await codex.thread_unarchive(reopened.id) - resumed_info = "n/a" - try: - resumed = await codex.thread_resume( - unarchived.id, - model="gpt-5.4", - config={"model_reasoning_effort": "high"}, - ) - resumed_result = await ( - await resumed.turn(TextInput("Continue in one short sentence.")) - ).run() - resumed_info = f"{resumed_result.id} {resumed_result.status}" - except Exception as exc: - resumed_info = f"skipped({type(exc).__name__})" + resumed = await codex.thread_resume( + unarchived.id, + model="gpt-5.4", + config={"model_reasoning_effort": "high"}, + ) + resumed_result = await ( + await resumed.turn(TextInput("Continue in one short sentence.")) + ).run() - forked_info = "n/a" - try: - forked = await codex.thread_fork(unarchived.id, model="gpt-5.4") - forked_result = await ( - await forked.turn(TextInput("Take a different angle in one short sentence.")) - ).run() - forked_info = f"{forked_result.id} {forked_result.status}" - except Exception as exc: - forked_info = f"skipped({type(exc).__name__})" + forked = await codex.thread_fork(unarchived.id, model="gpt-5.4") + forked_result = await ( + await forked.turn(TextInput("Take a different angle in one short sentence.")) + ).run() - compact_info = "sent" - try: - _ = await unarchived.compact() - except Exception as exc: - compact_info = f"skipped({type(exc).__name__})" + compact_result = await unarchived.compact() print("Lifecycle OK:", thread.id) print("first:", first.id, first.status) print("second:", second.id, second.status) - print("read.turns:", len(reading.thread.turns or [])) + print("read.turns:", len(reading.thread.turns)) print("list.active:", len(listing_active.data)) print("list.archived:", len(listing_archived.data)) - print("resumed:", resumed_info) - print("forked:", forked_info) - print("compact:", compact_info) + print("resumed:", resumed_result.id, resumed_result.status) + print("forked:", forked_result.id, forked_result.status) + print("compact:", compact_result.model_dump(mode="json", by_alias=True)) if __name__ == "__main__": diff --git a/sdk/python/examples/06_thread_lifecycle_and_controls/sync.py b/sdk/python/examples/06_thread_lifecycle_and_controls/sync.py index d01f165235..0958002dd9 100644 --- a/sdk/python/examples/06_thread_lifecycle_and_controls/sync.py +++ b/sdk/python/examples/06_thread_lifecycle_and_controls/sync.py @@ -25,40 +25,24 @@ with Codex(config=runtime_config()) as codex: listing_archived = codex.thread_list(limit=20, archived=True) unarchived = codex.thread_unarchive(reopened.id) - resumed_info = "n/a" - try: - resumed = codex.thread_resume( - unarchived.id, - model="gpt-5.4", - config={"model_reasoning_effort": "high"}, - ) - resumed_result = resumed.turn(TextInput("Continue in one short sentence.")).run() - resumed_info = f"{resumed_result.id} {resumed_result.status}" - except Exception as exc: - resumed_info = f"skipped({type(exc).__name__})" + resumed = codex.thread_resume( + unarchived.id, + model="gpt-5.4", + config={"model_reasoning_effort": "high"}, + ) + resumed_result = resumed.turn(TextInput("Continue in one short sentence.")).run() - forked_info = "n/a" - try: - forked = codex.thread_fork(unarchived.id, model="gpt-5.4") - forked_result = forked.turn( - TextInput("Take a different angle in one short sentence.") - ).run() - forked_info = f"{forked_result.id} {forked_result.status}" - except Exception as exc: - forked_info = f"skipped({type(exc).__name__})" + forked = codex.thread_fork(unarchived.id, model="gpt-5.4") + forked_result = forked.turn(TextInput("Take a different angle in one short sentence.")).run() - compact_info = "sent" - try: - _ = unarchived.compact() - except Exception as exc: - compact_info = f"skipped({type(exc).__name__})" + compact_result = unarchived.compact() print("Lifecycle OK:", thread.id) print("first:", first.id, first.status) print("second:", second.id, second.status) - print("read.turns:", len(reading.thread.turns or [])) + print("read.turns:", len(reading.thread.turns)) print("list.active:", len(listing_active.data)) print("list.archived:", len(listing_archived.data)) - print("resumed:", resumed_info) - print("forked:", forked_info) - print("compact:", compact_info) + print("resumed:", resumed_result.id, resumed_result.status) + print("forked:", forked_result.id, forked_result.status) + print("compact:", compact_result.model_dump(mode="json", by_alias=True)) diff --git a/sdk/python/examples/07_image_and_text/async.py b/sdk/python/examples/07_image_and_text/async.py index 41fe96a830..c3237e320e 100644 --- a/sdk/python/examples/07_image_and_text/async.py +++ b/sdk/python/examples/07_image_and_text/async.py @@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -33,11 +28,9 @@ async def main() -> None: ] ) result = await turn.run() - persisted = await thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) print("Status:", result.status) - print(assistant_text_from_turn(persisted_turn)) + print(result.final_response) if __name__ == "__main__": diff --git a/sdk/python/examples/07_image_and_text/sync.py b/sdk/python/examples/07_image_and_text/sync.py index ca82e7cfdc..f7402f18c9 100644 --- a/sdk/python/examples/07_image_and_text/sync.py +++ b/sdk/python/examples/07_image_and_text/sync.py @@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -26,8 +21,6 @@ with Codex(config=runtime_config()) as codex: ImageInput(REMOTE_IMAGE_URL), ] ).run() - persisted = thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) print("Status:", result.status) - print(assistant_text_from_turn(persisted_turn)) + print(result.final_response) diff --git a/sdk/python/examples/08_local_image_and_text/async.py b/sdk/python/examples/08_local_image_and_text/async.py index c3abf4d2ee..292c0c3e3d 100644 --- a/sdk/python/examples/08_local_image_and_text/async.py +++ b/sdk/python/examples/08_local_image_and_text/async.py @@ -6,9 +6,7 @@ if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) from _bootstrap import ( - assistant_text_from_turn, ensure_local_sdk_src, - find_turn_by_id, runtime_config, temporary_sample_image_path, ) @@ -36,11 +34,9 @@ async def main() -> None: ] ) result = await turn.run() - persisted = await thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) print("Status:", result.status) - print(assistant_text_from_turn(persisted_turn)) + print(result.final_response) if __name__ == "__main__": diff --git a/sdk/python/examples/08_local_image_and_text/sync.py b/sdk/python/examples/08_local_image_and_text/sync.py index 45c5161cb8..ee80a181ff 100644 --- a/sdk/python/examples/08_local_image_and_text/sync.py +++ b/sdk/python/examples/08_local_image_and_text/sync.py @@ -6,9 +6,7 @@ if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) from _bootstrap import ( - assistant_text_from_turn, ensure_local_sdk_src, - find_turn_by_id, runtime_config, temporary_sample_image_path, ) @@ -29,8 +27,6 @@ with temporary_sample_image_path() as image_path: LocalImageInput(str(image_path.resolve())), ] ).run() - persisted = thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) print("Status:", result.status) - print(assistant_text_from_turn(persisted_turn)) + print(result.final_response) diff --git a/sdk/python/examples/09_async_parity/sync.py b/sdk/python/examples/09_async_parity/sync.py index 018e588524..4b5d567159 100644 --- a/sdk/python/examples/09_async_parity/sync.py +++ b/sdk/python/examples/09_async_parity/sync.py @@ -5,13 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, - server_label, -) +from _bootstrap import ensure_local_sdk_src, runtime_config, server_label ensure_local_sdk_src() @@ -23,9 +17,7 @@ with Codex(config=runtime_config()) as codex: thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) turn = thread.turn(TextInput("Say hello in one sentence.")) result = turn.run() - persisted = thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) print("Thread:", thread.id) print("Turn:", result.id) - print("Text:", assistant_text_from_turn(persisted_turn).strip()) + print("Text:", result.final_response.strip()) diff --git a/sdk/python/examples/10_error_handling_and_retry/async.py b/sdk/python/examples/10_error_handling_and_retry/async.py index 65c95b2b63..f1711bffc9 100644 --- a/sdk/python/examples/10_error_handling_and_retry/async.py +++ b/sdk/python/examples/10_error_handling_and_retry/async.py @@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -73,19 +68,16 @@ async def main() -> None: ) except ServerBusyError as exc: print("Server overloaded after retries:", exc.message) - print("Text:") return except JsonRpcError as exc: print(f"JSON-RPC error {exc.code}: {exc.message}") - print("Text:") return - persisted = await thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) if result.status == TurnStatus.failed: print("Turn failed:", result.error) + return - print("Text:", assistant_text_from_turn(persisted_turn)) + print("Text:", result.final_response) def _run_turn(thread, prompt: str): diff --git a/sdk/python/examples/10_error_handling_and_retry/sync.py b/sdk/python/examples/10_error_handling_and_retry/sync.py index 24ee243db3..c134063c80 100644 --- a/sdk/python/examples/10_error_handling_and_retry/sync.py +++ b/sdk/python/examples/10_error_handling_and_retry/sync.py @@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -35,13 +30,10 @@ with Codex(config=runtime_config()) as codex: ) except ServerBusyError as exc: print("Server overloaded after retries:", exc.message) - print("Text:") except JsonRpcError as exc: print(f"JSON-RPC error {exc.code}: {exc.message}") - print("Text:") else: - persisted = thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) if result.status == TurnStatus.failed: print("Turn failed:", result.error) - print("Text:", assistant_text_from_turn(persisted_turn)) + else: + print("Text:", result.final_response) diff --git a/sdk/python/examples/11_cli_mini_app/async.py b/sdk/python/examples/11_cli_mini_app/async.py index 1b0a2cfd3a..df9fc89b86 100644 --- a/sdk/python/examples/11_cli_mini_app/async.py +++ b/sdk/python/examples/11_cli_mini_app/async.py @@ -21,19 +21,9 @@ from openai_codex.types import ( ) -def _status_value(status: object | None) -> str: - return str(getattr(status, "value", status)) - - -def _format_usage(usage: object | None) -> str: - if usage is None: - return "usage> (none)" - - last = getattr(usage, "last", None) - total = getattr(usage, "total", None) - if last is None or total is None: - return f"usage> {usage}" - +def _format_usage(usage: object) -> str: + last = usage.last + total = usage.total return ( "usage>\n" f" last: input={last.input_tokens} output={last.output_tokens} reasoning={last.reasoning_output_tokens} total={last.total_tokens} cached={last.cached_input_tokens}\n" @@ -65,16 +55,14 @@ async def main() -> None: usage = None status = None error = None - printed_delta = False print("assistant> ", end="", flush=True) async for event in turn.stream(): payload = event.payload if event.method == "item/agentMessage/delta": - delta = getattr(payload, "delta", "") + delta = payload.delta if delta: print(delta, end="", flush=True) - printed_delta = True continue if isinstance(payload, ThreadTokenUsageUpdatedNotification): usage = payload.token_usage @@ -83,12 +71,13 @@ async def main() -> None: status = payload.turn.status error = payload.turn.error - if printed_delta: - print() - else: - print("[no text]") + print() + if status is None: + raise RuntimeError("stream ended without turn/completed") + if usage is None: + raise RuntimeError("stream ended without token usage") - status_text = _status_value(status) + status_text = status.value print(f"assistant.status> {status_text}") if status_text == "failed": print("assistant.error>", error) diff --git a/sdk/python/examples/11_cli_mini_app/sync.py b/sdk/python/examples/11_cli_mini_app/sync.py index dcb801d29c..fd16f930dc 100644 --- a/sdk/python/examples/11_cli_mini_app/sync.py +++ b/sdk/python/examples/11_cli_mini_app/sync.py @@ -21,19 +21,9 @@ from openai_codex.types import ( print("Codex mini CLI. Type /exit to quit.") -def _status_value(status: object | None) -> str: - return str(getattr(status, "value", status)) - - -def _format_usage(usage: object | None) -> str: - if usage is None: - return "usage> (none)" - - last = getattr(usage, "last", None) - total = getattr(usage, "total", None) - if last is None or total is None: - return f"usage> {usage}" - +def _format_usage(usage: object) -> str: + last = usage.last + total = usage.total return ( "usage>\n" f" last: input={last.input_tokens} output={last.output_tokens} reasoning={last.reasoning_output_tokens} total={last.total_tokens} cached={last.cached_input_tokens}\n" @@ -60,16 +50,14 @@ with Codex(config=runtime_config()) as codex: usage = None status = None error = None - printed_delta = False print("assistant> ", end="", flush=True) for event in turn.stream(): payload = event.payload if event.method == "item/agentMessage/delta": - delta = getattr(payload, "delta", "") + delta = payload.delta if delta: print(delta, end="", flush=True) - printed_delta = True continue if isinstance(payload, ThreadTokenUsageUpdatedNotification): usage = payload.token_usage @@ -78,12 +66,13 @@ with Codex(config=runtime_config()) as codex: status = payload.turn.status error = payload.turn.error - if printed_delta: - print() - else: - print("[no text]") + print() + if status is None: + raise RuntimeError("stream ended without turn/completed") + if usage is None: + raise RuntimeError("stream ended without token usage") - status_text = _status_value(status) + status_text = status.value print(f"assistant.status> {status_text}") if status_text == "failed": print("assistant.error>", error) diff --git a/sdk/python/examples/12_turn_params_kitchen_sink/async.py b/sdk/python/examples/12_turn_params_kitchen_sink/async.py index 30b72f3173..258d674a88 100644 --- a/sdk/python/examples/12_turn_params_kitchen_sink/async.py +++ b/sdk/python/examples/12_turn_params_kitchen_sink/async.py @@ -6,12 +6,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -60,9 +55,7 @@ async def main() -> None: summary=SUMMARY, ) result = await turn.run() - persisted = await thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) - structured_text = assistant_text_from_turn(persisted_turn).strip() + structured_text = result.final_response.strip() try: structured = json.loads(structured_text) except json.JSONDecodeError as exc: @@ -70,8 +63,8 @@ async def main() -> None: f"Expected JSON matching OUTPUT_SCHEMA, got: {structured_text!r}" ) from exc - summary = structured.get("summary") - actions = structured.get("actions") + summary = structured["summary"] + actions = structured["actions"] if ( not isinstance(summary, str) or not isinstance(actions, list) @@ -86,7 +79,7 @@ async def main() -> None: print("actions:") for action in actions: print("-", action) - print("Items:", 0 if persisted_turn is None else len(persisted_turn.items or [])) + print("Items:", len(result.items)) if __name__ == "__main__": diff --git a/sdk/python/examples/12_turn_params_kitchen_sink/sync.py b/sdk/python/examples/12_turn_params_kitchen_sink/sync.py index dffe74a915..feced36f20 100644 --- a/sdk/python/examples/12_turn_params_kitchen_sink/sync.py +++ b/sdk/python/examples/12_turn_params_kitchen_sink/sync.py @@ -6,12 +6,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -54,9 +49,7 @@ with Codex(config=runtime_config()) as codex: summary=SUMMARY, ) result = turn.run() - persisted = thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) - structured_text = assistant_text_from_turn(persisted_turn).strip() + structured_text = result.final_response.strip() try: structured = json.loads(structured_text) except json.JSONDecodeError as exc: @@ -64,8 +57,8 @@ with Codex(config=runtime_config()) as codex: f"Expected JSON matching OUTPUT_SCHEMA, got: {structured_text!r}" ) from exc - summary = structured.get("summary") - actions = structured.get("actions") + summary = structured["summary"] + actions = structured["actions"] if ( not isinstance(summary, str) or not isinstance(actions, list) @@ -80,4 +73,4 @@ with Codex(config=runtime_config()) as codex: print("actions:") for action in actions: print("-", action) - print("Items:", 0 if persisted_turn is None else len(persisted_turn.items or [])) + print("Items:", len(result.items)) diff --git a/sdk/python/examples/13_model_select_and_turn_params/async.py b/sdk/python/examples/13_model_select_and_turn_params/async.py index f2810d8fd9..31a0c9c243 100644 --- a/sdk/python/examples/13_model_select_and_turn_params/async.py +++ b/sdk/python/examples/13_model_select_and_turn_params/async.py @@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -35,29 +30,27 @@ REASONING_RANK = { "high": 4, "xhigh": 5, } -PREFERRED_MODEL = "gpt-5.4" def _pick_highest_model(models): - visible = [m for m in models if not m.hidden] or models - preferred = next( - (m for m in visible if m.model == PREFERRED_MODEL or m.id == PREFERRED_MODEL), None - ) - if preferred is not None: - return preferred + visible = [m for m in models if not m.hidden] + if not visible: + raise RuntimeError("models response did not include visible models") + known_names = {m.id for m in visible} | {m.model for m in visible} top_candidates = [m for m in visible if not (m.upgrade and m.upgrade in known_names)] - pool = top_candidates or visible - return max(pool, key=lambda m: (m.model, m.id)) + if not top_candidates: + raise RuntimeError("models response did not include top-level visible models") + return max(top_candidates, key=lambda m: (m.model, m.id)) def _pick_highest_turn_effort(model) -> ReasoningEffort: if not model.supported_reasoning_efforts: - return ReasoningEffort.medium + raise RuntimeError(f"{model.model} did not advertise supported reasoning efforts") best = max( model.supported_reasoning_efforts, - key=lambda option: REASONING_RANK.get(option.reasoning_effort.value, -1), + key=lambda option: REASONING_RANK[option.reasoning_effort.value], ) return ReasoningEffort(best.reasoning_effort.value) @@ -103,13 +96,9 @@ async def main() -> None: effort=selected_effort, ) first = await first_turn.run() - persisted = await thread.read(include_turns=True) - first_persisted_turn = find_turn_by_id(persisted.thread.turns, first.id) - print("agent.message:", assistant_text_from_turn(first_persisted_turn)) - print( - "items:", 0 if first_persisted_turn is None else len(first_persisted_turn.items or []) - ) + print("agent.message:", first.final_response) + print("items:", len(first.items)) second_turn = await thread.turn( TextInput("Return JSON for a safe feature-flag rollout plan."), @@ -122,14 +111,9 @@ async def main() -> None: summary=ReasoningSummary.model_validate("concise"), ) second = await second_turn.run() - persisted = await thread.read(include_turns=True) - second_persisted_turn = find_turn_by_id(persisted.thread.turns, second.id) - print("agent.message.params:", assistant_text_from_turn(second_persisted_turn)) - print( - "items.params:", - 0 if second_persisted_turn is None else len(second_persisted_turn.items or []), - ) + print("agent.message.params:", second.final_response) + print("items.params:", len(second.items)) if __name__ == "__main__": diff --git a/sdk/python/examples/13_model_select_and_turn_params/sync.py b/sdk/python/examples/13_model_select_and_turn_params/sync.py index b1154ce171..9252f3913c 100644 --- a/sdk/python/examples/13_model_select_and_turn_params/sync.py +++ b/sdk/python/examples/13_model_select_and_turn_params/sync.py @@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -33,29 +28,27 @@ REASONING_RANK = { "high": 4, "xhigh": 5, } -PREFERRED_MODEL = "gpt-5.4" def _pick_highest_model(models): - visible = [m for m in models if not m.hidden] or models - preferred = next( - (m for m in visible if m.model == PREFERRED_MODEL or m.id == PREFERRED_MODEL), None - ) - if preferred is not None: - return preferred + visible = [m for m in models if not m.hidden] + if not visible: + raise RuntimeError("models response did not include visible models") + known_names = {m.id for m in visible} | {m.model for m in visible} top_candidates = [m for m in visible if not (m.upgrade and m.upgrade in known_names)] - pool = top_candidates or visible - return max(pool, key=lambda m: (m.model, m.id)) + if not top_candidates: + raise RuntimeError("models response did not include top-level visible models") + return max(top_candidates, key=lambda m: (m.model, m.id)) def _pick_highest_turn_effort(model) -> ReasoningEffort: if not model.supported_reasoning_efforts: - return ReasoningEffort.medium + raise RuntimeError(f"{model.model} did not advertise supported reasoning efforts") best = max( model.supported_reasoning_efforts, - key=lambda option: REASONING_RANK.get(option.reasoning_effort.value, -1), + key=lambda option: REASONING_RANK[option.reasoning_effort.value], ) return ReasoningEffort(best.reasoning_effort.value) @@ -99,11 +92,9 @@ with Codex(config=runtime_config()) as codex: model=selected_model.model, effort=selected_effort, ).run() - persisted = thread.read(include_turns=True) - first_turn = find_turn_by_id(persisted.thread.turns, first.id) - print("agent.message:", assistant_text_from_turn(first_turn)) - print("items:", 0 if first_turn is None else len(first_turn.items or [])) + print("agent.message:", first.final_response) + print("items:", len(first.items)) second = thread.turn( TextInput("Return JSON for a safe feature-flag rollout plan."), @@ -115,8 +106,6 @@ with Codex(config=runtime_config()) as codex: sandbox_policy=SANDBOX_POLICY, summary=ReasoningSummary.model_validate("concise"), ).run() - persisted = thread.read(include_turns=True) - second_turn = find_turn_by_id(persisted.thread.turns, second.id) - print("agent.message.params:", assistant_text_from_turn(second_turn)) - print("items.params:", 0 if second_turn is None else len(second_turn.items or [])) + print("agent.message.params:", second.final_response) + print("items.params:", len(second.items)) diff --git a/sdk/python/examples/14_turn_controls/async.py b/sdk/python/examples/14_turn_controls/async.py index 4f2777c2e4..f044dadbbe 100644 --- a/sdk/python/examples/14_turn_controls/async.py +++ b/sdk/python/examples/14_turn_controls/async.py @@ -5,11 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ( - assistant_text_from_turn, - ensure_local_sdk_src, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -26,56 +22,48 @@ async def main() -> None: steer_turn = await thread.turn( TextInput("Count from 1 to 40 with commas, then one summary sentence.") ) - steer_result = "sent" - try: - _ = await steer_turn.steer(TextInput("Keep it brief and stop after 10 numbers.")) - except Exception as exc: - steer_result = f"skipped {type(exc).__name__}" + steer_result = await steer_turn.steer(TextInput("Keep it brief and stop after 10 numbers.")) steer_event_count = 0 - steer_completed_status = "unknown" - steer_completed_turn = None + steer_completed_status = None + steer_deltas = [] async for event in steer_turn.stream(): steer_event_count += 1 + if event.method == "item/agentMessage/delta": + steer_deltas.append(event.payload.delta) + continue if event.method == "turn/completed": - steer_completed_turn = event.payload.turn - steer_completed_status = getattr( - event.payload.turn.status, "value", str(event.payload.turn.status) - ) + steer_completed_status = event.payload.turn.status.value - steer_preview = ( - assistant_text_from_turn(steer_completed_turn).strip() or "[no assistant text]" - ) + if steer_completed_status is None: + raise RuntimeError("stream ended without turn/completed") + steer_preview = "".join(steer_deltas).strip() interrupt_turn = await thread.turn( TextInput("Count from 1 to 200 with commas, then one summary sentence.") ) - interrupt_result = "sent" - try: - _ = await interrupt_turn.interrupt() - except Exception as exc: - interrupt_result = f"skipped {type(exc).__name__}" + interrupt_result = await interrupt_turn.interrupt() interrupt_event_count = 0 - interrupt_completed_status = "unknown" - interrupt_completed_turn = None + interrupt_completed_status = None + interrupt_deltas = [] async for event in interrupt_turn.stream(): interrupt_event_count += 1 + if event.method == "item/agentMessage/delta": + interrupt_deltas.append(event.payload.delta) + continue if event.method == "turn/completed": - interrupt_completed_turn = event.payload.turn - interrupt_completed_status = getattr( - event.payload.turn.status, "value", str(event.payload.turn.status) - ) + interrupt_completed_status = event.payload.turn.status.value - interrupt_preview = ( - assistant_text_from_turn(interrupt_completed_turn).strip() or "[no assistant text]" - ) + if interrupt_completed_status is None: + raise RuntimeError("stream ended without turn/completed") + interrupt_preview = "".join(interrupt_deltas).strip() - print("steer.result:", steer_result) + print("steer.result:", steer_result.model_dump(mode="json", by_alias=True)) print("steer.final.status:", steer_completed_status) print("steer.events.count:", steer_event_count) print("steer.assistant.preview:", steer_preview) - print("interrupt.result:", interrupt_result) + print("interrupt.result:", interrupt_result.model_dump(mode="json", by_alias=True)) print("interrupt.final.status:", interrupt_completed_status) print("interrupt.events.count:", interrupt_event_count) print("interrupt.assistant.preview:", interrupt_preview) diff --git a/sdk/python/examples/14_turn_controls/sync.py b/sdk/python/examples/14_turn_controls/sync.py index 03180ba8eb..6b6d046a43 100644 --- a/sdk/python/examples/14_turn_controls/sync.py +++ b/sdk/python/examples/14_turn_controls/sync.py @@ -5,11 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ( - assistant_text_from_turn, - ensure_local_sdk_src, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -20,54 +16,48 @@ with Codex(config=runtime_config()) as codex: steer_turn = thread.turn( TextInput("Count from 1 to 40 with commas, then one summary sentence.") ) - steer_result = "sent" - try: - _ = steer_turn.steer(TextInput("Keep it brief and stop after 10 numbers.")) - except Exception as exc: - steer_result = f"skipped {type(exc).__name__}" + steer_result = steer_turn.steer(TextInput("Keep it brief and stop after 10 numbers.")) steer_event_count = 0 - steer_completed_status = "unknown" - steer_completed_turn = None + steer_completed_status = None + steer_deltas = [] for event in steer_turn.stream(): steer_event_count += 1 + if event.method == "item/agentMessage/delta": + steer_deltas.append(event.payload.delta) + continue if event.method == "turn/completed": - steer_completed_turn = event.payload.turn - steer_completed_status = getattr( - event.payload.turn.status, "value", str(event.payload.turn.status) - ) + steer_completed_status = event.payload.turn.status.value - steer_preview = assistant_text_from_turn(steer_completed_turn).strip() or "[no assistant text]" + if steer_completed_status is None: + raise RuntimeError("stream ended without turn/completed") + steer_preview = "".join(steer_deltas).strip() interrupt_turn = thread.turn( TextInput("Count from 1 to 200 with commas, then one summary sentence.") ) - interrupt_result = "sent" - try: - _ = interrupt_turn.interrupt() - except Exception as exc: - interrupt_result = f"skipped {type(exc).__name__}" + interrupt_result = interrupt_turn.interrupt() interrupt_event_count = 0 - interrupt_completed_status = "unknown" - interrupt_completed_turn = None + interrupt_completed_status = None + interrupt_deltas = [] for event in interrupt_turn.stream(): interrupt_event_count += 1 + if event.method == "item/agentMessage/delta": + interrupt_deltas.append(event.payload.delta) + continue if event.method == "turn/completed": - interrupt_completed_turn = event.payload.turn - interrupt_completed_status = getattr( - event.payload.turn.status, "value", str(event.payload.turn.status) - ) + interrupt_completed_status = event.payload.turn.status.value - interrupt_preview = ( - assistant_text_from_turn(interrupt_completed_turn).strip() or "[no assistant text]" - ) + if interrupt_completed_status is None: + raise RuntimeError("stream ended without turn/completed") + interrupt_preview = "".join(interrupt_deltas).strip() - print("steer.result:", steer_result) + print("steer.result:", steer_result.model_dump(mode="json", by_alias=True)) print("steer.final.status:", steer_completed_status) print("steer.events.count:", steer_event_count) print("steer.assistant.preview:", steer_preview) - print("interrupt.result:", interrupt_result) + print("interrupt.result:", interrupt_result.model_dump(mode="json", by_alias=True)) print("interrupt.final.status:", interrupt_completed_status) print("interrupt.events.count:", interrupt_event_count) print("interrupt.assistant.preview:", interrupt_preview) diff --git a/sdk/python/examples/README.md b/sdk/python/examples/README.md index 569a51f477..bef1a16c0b 100644 --- a/sdk/python/examples/README.md +++ b/sdk/python/examples/README.md @@ -81,6 +81,6 @@ python examples/01_quickstart_constructor/async.py - `13_model_select_and_turn_params/` - list models, pick highest model + highest supported reasoning effort, run turns, print message and usage - `14_turn_controls/` - - separate best-effort `steer()` and `interrupt()` demos with concise summaries + - separate `steer()` and `interrupt()` demos with concise summaries - `15_login_and_account/` - browser-login handle lifecycle, cancellation, and account inspection diff --git a/sdk/python/examples/_bootstrap.py b/sdk/python/examples/_bootstrap.py index af115119ac..92fe18e26a 100644 --- a/sdk/python/examples/_bootstrap.py +++ b/sdk/python/examples/_bootstrap.py @@ -6,7 +6,7 @@ import sys import tempfile import zlib from pathlib import Path -from typing import Iterable, Iterator +from typing import Any, Iterator _SDK_PYTHON_DIR = Path(__file__).resolve().parents[1] _SDK_PYTHON_STR = str(_SDK_PYTHON_DIR) @@ -103,53 +103,5 @@ def temporary_sample_image_path() -> Iterator[Path]: yield image_path -def server_label(metadata: object) -> str: - server = getattr(metadata, "serverInfo", None) - server_name = ((getattr(server, "name", None) or "") if server is not None else "").strip() - server_version = ( - (getattr(server, "version", None) or "") if server is not None else "" - ).strip() - if server_name and server_version: - return f"{server_name} {server_version}" - - user_agent = ( - (getattr(metadata, "userAgent", None) or "") if metadata is not None else "" - ).strip() - return user_agent or "unknown" - - -def find_turn_by_id(turns: Iterable[object] | None, turn_id: str) -> object | None: - for turn in turns or []: - if getattr(turn, "id", None) == turn_id: - return turn - return None - - -def assistant_text_from_turn(turn: object | None) -> str: - if turn is None: - return "" - - chunks: list[str] = [] - for item in getattr(turn, "items", []) or []: - raw_item = item.model_dump(mode="json") if hasattr(item, "model_dump") else item - if not isinstance(raw_item, dict): - continue - - item_type = raw_item.get("type") - if item_type == "agentMessage": - text = raw_item.get("text") - if isinstance(text, str) and text: - chunks.append(text) - continue - - if item_type != "message" or raw_item.get("role") != "assistant": - continue - - for content in raw_item.get("content") or []: - if not isinstance(content, dict) or content.get("type") != "output_text": - continue - text = content.get("text") - if isinstance(text, str) and text: - chunks.append(text) - - return "".join(chunks) +def server_label(metadata: Any) -> str: + return f"{metadata.serverInfo.name} {metadata.serverInfo.version}" diff --git a/sdk/python/notebooks/sdk_walkthrough.ipynb b/sdk/python/notebooks/sdk_walkthrough.ipynb index e51c217dbd..1eb80b3a71 100644 --- a/sdk/python/notebooks/sdk_walkthrough.ipynb +++ b/sdk/python/notebooks/sdk_walkthrough.ipynb @@ -25,23 +25,10 @@ " f'Notebook requires Python 3.10+; current interpreter is {sys.version.split()[0]}.'\n", " )\n", "\n", - "try:\n", - " _ = os.getcwd()\n", - "except FileNotFoundError:\n", - " os.chdir(str(Path.home()))\n", - "\n", - "\n", "def _is_sdk_python_dir(path: Path) -> bool:\n", " return (path / 'pyproject.toml').exists() and (path / 'src' / 'openai_codex').exists()\n", "\n", "\n", - "def _iter_home_fallback_candidates(home: Path):\n", - " # bounded depth scan under home to support launching notebooks from unrelated cwd values\n", - " patterns = ('sdk/python', '*/sdk/python', '*/*/sdk/python', '*/*/*/sdk/python')\n", - " for pattern in patterns:\n", - " yield from home.glob(pattern)\n", - "\n", - "\n", "def _find_sdk_python_dir(start: Path) -> Path | None:\n", " checked = set()\n", "\n", @@ -70,21 +57,6 @@ " if found is not None:\n", " return found\n", "\n", - " for entry in sys.path:\n", - " if not entry:\n", - " continue\n", - " entry_path = Path(entry).expanduser()\n", - " for candidate in (entry_path, entry_path / 'sdk' / 'python'):\n", - " found = _consider(candidate)\n", - " if found is not None:\n", - " return found\n", - "\n", - " home = Path.home()\n", - " for candidate in _iter_home_fallback_candidates(home):\n", - " found = _consider(candidate)\n", - " if found is not None:\n", - " return found\n", - "\n", " return None\n", "\n", "\n", @@ -129,7 +101,7 @@ "outputs": [], "source": [ "# Cell 2: imports (public only)\n", - "from _bootstrap import assistant_text_from_turn, find_turn_by_id, server_label\n", + "from _bootstrap import server_label\n", "from openai_codex import (\n", " AsyncCodex,\n", " Codex,\n", @@ -172,12 +144,10 @@ " thread = codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n", " turn = thread.turn(TextInput('Explain gradient descent in 3 bullets.'))\n", " result = turn.run()\n", - " persisted = thread.read(include_turns=True)\n", - " persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)\n", - "\n", " print('server:', server_label(codex.metadata))\n", " print('status:', result.status)\n", - " print(assistant_text_from_turn(persisted_turn))\n" + " print(result.final_response)\n", + " print('items:', len(result.items))\n" ] }, { @@ -192,12 +162,9 @@ "\n", " first = thread.turn(TextInput('Give a short summary of transformers.')).run()\n", " second = thread.turn(TextInput('Now explain that to a high-school student.')).run()\n", - " persisted = thread.read(include_turns=True)\n", - " second_turn = find_turn_by_id(persisted.thread.turns, second.id)\n", - "\n", " print('first status:', first.status)\n", " print('second status:', second.status)\n", - " print('second text:', assistant_text_from_turn(second_turn))\n" + " print('second text:', second.final_response)\n" ] }, { @@ -221,41 +188,27 @@ " listing_archived = codex.thread_list(limit=20, archived=True)\n", " unarchived = codex.thread_unarchive(reopened.id)\n", "\n", - " resumed_info = 'n/a'\n", - " try:\n", - " resumed = codex.thread_resume(\n", - " unarchived.id,\n", - " model='gpt-5.4',\n", - " config={'model_reasoning_effort': 'high'},\n", - " )\n", - " resumed_result = resumed.turn(TextInput('Continue in one short sentence.')).run()\n", - " resumed_info = f'{resumed_result.id} {resumed_result.status}'\n", - " except Exception as e:\n", - " resumed_info = f'skipped({type(e).__name__})'\n", + " resumed = codex.thread_resume(\n", + " unarchived.id,\n", + " model='gpt-5.4',\n", + " config={'model_reasoning_effort': 'high'},\n", + " )\n", + " resumed_result = resumed.turn(TextInput('Continue in one short sentence.')).run()\n", "\n", - " forked_info = 'n/a'\n", - " try:\n", - " forked = codex.thread_fork(unarchived.id, model='gpt-5.4')\n", - " forked_result = forked.turn(TextInput('Take a different angle in one short sentence.')).run()\n", - " forked_info = f'{forked_result.id} {forked_result.status}'\n", - " except Exception as e:\n", - " forked_info = f'skipped({type(e).__name__})'\n", + " forked = codex.thread_fork(unarchived.id, model='gpt-5.4')\n", + " forked_result = forked.turn(TextInput('Take a different angle in one short sentence.')).run()\n", "\n", - " compact_info = 'sent'\n", - " try:\n", - " _ = unarchived.compact()\n", - " except Exception as e:\n", - " compact_info = f'skipped({type(e).__name__})'\n", + " compact_result = unarchived.compact()\n", "\n", " print('Lifecycle OK:', thread.id)\n", " print('first:', first.id, first.status)\n", " print('second:', second.id, second.status)\n", - " print('read.turns:', len(reading.thread.turns or []))\n", + " print('read.turns:', len(reading.thread.turns))\n", " print('list.active:', len(listing_active.data))\n", " print('list.archived:', len(listing_archived.data))\n", - " print('resumed:', resumed_info)\n", - " print('forked:', forked_info)\n", - " print('compact:', compact_info)\n" + " print('resumed:', resumed_result.id, resumed_result.status)\n", + " print('forked:', forked_result.id, forked_result.status)\n", + " print('compact:', compact_result.model_dump(mode='json', by_alias=True))\n" ] }, { @@ -299,11 +252,8 @@ " summary=summary,\n", " )\n", " result = turn.run()\n", - " persisted = thread.read(include_turns=True)\n", - " persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)\n", - "\n", " print('status:', result.status)\n", - " print(assistant_text_from_turn(persisted_turn))\n" + " print(result.final_response)\n" ] }, { @@ -332,17 +282,20 @@ "\n", "\n", "def pick_highest_model(models):\n", - " visible = [m for m in models if not m.hidden] or models\n", + " visible = [m for m in models if not m.hidden]\n", + " if not visible:\n", + " raise RuntimeError('models response did not include visible models')\n", " known_names = {m.id for m in visible} | {m.model for m in visible}\n", " top_candidates = [m for m in visible if not (m.upgrade and m.upgrade in known_names)]\n", - " pool = top_candidates or visible\n", - " return max(pool, key=lambda m: (m.model, m.id))\n", + " if not top_candidates:\n", + " raise RuntimeError('models response did not include top-level visible models')\n", + " return max(top_candidates, key=lambda m: (m.model, m.id))\n", "\n", "\n", "def pick_highest_turn_effort(model) -> ReasoningEffort:\n", " if not model.supported_reasoning_efforts:\n", - " return ReasoningEffort.medium\n", - " best = max(model.supported_reasoning_efforts, key=lambda opt: reasoning_rank.get(opt.reasoning_effort.value, -1))\n", + " raise RuntimeError(f'{model.model} did not advertise supported reasoning efforts')\n", + " best = max(model.supported_reasoning_efforts, key=lambda opt: reasoning_rank[opt.reasoning_effort.value])\n", " return ReasoningEffort(best.reasoning_effort.value)\n", "\n", "\n", @@ -372,10 +325,8 @@ " model=selected_model.model,\n", " effort=selected_effort,\n", " ).run()\n", - " persisted = thread.read(include_turns=True)\n", - " first_turn = find_turn_by_id(persisted.thread.turns, first.id)\n", - " print('agent.message:', assistant_text_from_turn(first_turn))\n", - " print('items:', 0 if first_turn is None else len(first_turn.items or []))\n", + " print('agent.message:', first.final_response)\n", + " print('items:', len(first.items))\n", "\n", " second = thread.turn(\n", " TextInput('Return JSON for a safe feature-flag rollout plan.'),\n", @@ -387,10 +338,8 @@ " sandbox_policy=sandbox_policy,\n", " summary=ReasoningSummary.model_validate('concise'),\n", " ).run()\n", - " persisted = thread.read(include_turns=True)\n", - " second_turn = find_turn_by_id(persisted.thread.turns, second.id)\n", - " print('agent.message.params:', assistant_text_from_turn(second_turn))\n", - " print('items.params:', 0 if second_turn is None else len(second_turn.items or []))\n" + " print('agent.message.params:', second.final_response)\n", + " print('items.params:', len(second.items))\n" ] }, { @@ -408,11 +357,8 @@ " TextInput('What do you see in this image? 3 bullets.'),\n", " ImageInput(remote_image_url),\n", " ]).run()\n", - " persisted = thread.read(include_turns=True)\n", - " persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)\n", - "\n", " print('status:', result.status)\n", - " print(assistant_text_from_turn(persisted_turn))\n" + " print(result.final_response)\n" ] }, { @@ -429,11 +375,8 @@ " TextInput('Describe the colors and layout in this generated local image in 2 bullets.'),\n", " LocalImageInput(str(local_image_path.resolve())),\n", " ]).run()\n", - " persisted = thread.read(include_turns=True)\n", - " persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)\n", - "\n", " print('status:', result.status)\n", - " print(assistant_text_from_turn(persisted_turn))\n" + " print(result.final_response)\n" ] }, { @@ -452,11 +395,8 @@ " initial_delay_s=0.25,\n", " max_delay_s=2.0,\n", " )\n", - " persisted = thread.read(include_turns=True)\n", - " persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)\n", - "\n", " print('status:', result.status)\n", - " print(assistant_text_from_turn(persisted_turn))\n" + " print(result.final_response)\n" ] }, { @@ -484,41 +424,27 @@ " listing_archived = await codex.thread_list(limit=20, archived=True)\n", " unarchived = await codex.thread_unarchive(reopened.id)\n", "\n", - " resumed_info = 'n/a'\n", - " try:\n", - " resumed = await codex.thread_resume(\n", - " unarchived.id,\n", - " model='gpt-5.4',\n", - " config={'model_reasoning_effort': 'high'},\n", - " )\n", - " resumed_result = await (await resumed.turn(TextInput('Continue in one short sentence.'))).run()\n", - " resumed_info = f'{resumed_result.id} {resumed_result.status}'\n", - " except Exception as e:\n", - " resumed_info = f'skipped({type(e).__name__})'\n", + " resumed = await codex.thread_resume(\n", + " unarchived.id,\n", + " model='gpt-5.4',\n", + " config={'model_reasoning_effort': 'high'},\n", + " )\n", + " resumed_result = await (await resumed.turn(TextInput('Continue in one short sentence.'))).run()\n", "\n", - " forked_info = 'n/a'\n", - " try:\n", - " forked = await codex.thread_fork(unarchived.id, model='gpt-5.4')\n", - " forked_result = await (await forked.turn(TextInput('Take a different angle in one short sentence.'))).run()\n", - " forked_info = f'{forked_result.id} {forked_result.status}'\n", - " except Exception as e:\n", - " forked_info = f'skipped({type(e).__name__})'\n", + " forked = await codex.thread_fork(unarchived.id, model='gpt-5.4')\n", + " forked_result = await (await forked.turn(TextInput('Take a different angle in one short sentence.'))).run()\n", "\n", - " compact_info = 'sent'\n", - " try:\n", - " _ = await unarchived.compact()\n", - " except Exception as e:\n", - " compact_info = f'skipped({type(e).__name__})'\n", + " compact_result = await unarchived.compact()\n", "\n", " print('Lifecycle OK:', thread.id)\n", " print('first:', first.id, first.status)\n", " print('second:', second.id, second.status)\n", - " print('read.turns:', len(reading.thread.turns or []))\n", + " print('read.turns:', len(reading.thread.turns))\n", " print('list.active:', len(listing_active.data))\n", " print('list.archived:', len(listing_archived.data))\n", - " print('resumed:', resumed_info)\n", - " print('forked:', forked_info)\n", - " print('compact:', compact_info)\n", + " print('resumed:', resumed_result.id, resumed_result.status)\n", + " print('forked:', forked_result.id, forked_result.status)\n", + " print('compact:', compact_result.model_dump(mode='json', by_alias=True))\n", "\n", "\n", "await async_lifecycle_demo()\n" @@ -530,7 +456,7 @@ "metadata": {}, "outputs": [], "source": [ - "# Cell 10: async turn controls (best effort steer + interrupt)\n", + "# Cell 10: async turn controls (steer + interrupt)\n", "import asyncio\n", "\n", "\n", @@ -539,46 +465,46 @@ " thread = await codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n", " steer_turn = await thread.turn(TextInput('Count from 1 to 40 with commas, then one summary sentence.'))\n", "\n", - " steer_result = 'sent'\n", - " try:\n", - " _ = await steer_turn.steer(TextInput('Keep it brief and stop after 10 numbers.'))\n", - " except Exception as e:\n", - " steer_result = f'skipped {type(e).__name__}'\n", + " steer_result = await steer_turn.steer(TextInput('Keep it brief and stop after 10 numbers.'))\n", "\n", " steer_event_count = 0\n", - " steer_completed_status = 'unknown'\n", - " steer_completed_turn = None\n", + " steer_completed_status = None\n", + " steer_deltas = []\n", " async for event in steer_turn.stream():\n", " steer_event_count += 1\n", + " if event.method == 'item/agentMessage/delta':\n", + " steer_deltas.append(event.payload.delta)\n", + " continue\n", " if event.method == 'turn/completed':\n", - " steer_completed_turn = event.payload.turn\n", - " steer_completed_status = getattr(event.payload.turn.status, 'value', str(event.payload.turn.status))\n", + " steer_completed_status = event.payload.turn.status.value\n", "\n", - " steer_preview = assistant_text_from_turn(steer_completed_turn).strip() or '[no assistant text]'\n", + " if steer_completed_status is None:\n", + " raise RuntimeError('stream ended without turn/completed')\n", + " steer_preview = ''.join(steer_deltas).strip()\n", "\n", " interrupt_turn = await thread.turn(TextInput('Count from 1 to 200 with commas, then one summary sentence.'))\n", - " interrupt_result = 'sent'\n", - " try:\n", - " _ = await interrupt_turn.interrupt()\n", - " except Exception as e:\n", - " interrupt_result = f'skipped {type(e).__name__}'\n", + " interrupt_result = await interrupt_turn.interrupt()\n", "\n", " interrupt_event_count = 0\n", - " interrupt_completed_status = 'unknown'\n", - " interrupt_completed_turn = None\n", + " interrupt_completed_status = None\n", + " interrupt_deltas = []\n", " async for event in interrupt_turn.stream():\n", " interrupt_event_count += 1\n", + " if event.method == 'item/agentMessage/delta':\n", + " interrupt_deltas.append(event.payload.delta)\n", + " continue\n", " if event.method == 'turn/completed':\n", - " interrupt_completed_turn = event.payload.turn\n", - " interrupt_completed_status = getattr(event.payload.turn.status, 'value', str(event.payload.turn.status))\n", + " interrupt_completed_status = event.payload.turn.status.value\n", "\n", - " interrupt_preview = assistant_text_from_turn(interrupt_completed_turn).strip() or '[no assistant text]'\n", + " if interrupt_completed_status is None:\n", + " raise RuntimeError('stream ended without turn/completed')\n", + " interrupt_preview = ''.join(interrupt_deltas).strip()\n", "\n", - " print('steer.result:', steer_result)\n", + " print('steer.result:', steer_result.model_dump(mode='json', by_alias=True))\n", " print('steer.final.status:', steer_completed_status)\n", " print('steer.events.count:', steer_event_count)\n", " print('steer.assistant.preview:', steer_preview)\n", - " print('interrupt.result:', interrupt_result)\n", + " print('interrupt.result:', interrupt_result.model_dump(mode='json', by_alias=True))\n", " print('interrupt.final.status:', interrupt_completed_status)\n", " print('interrupt.events.count:', interrupt_event_count)\n", " print('interrupt.assistant.preview:', interrupt_preview)\n", diff --git a/sdk/python/src/openai_codex/__init__.py b/sdk/python/src/openai_codex/__init__.py index 8c8dcc3eb2..b3db7e3175 100644 --- a/sdk/python/src/openai_codex/__init__.py +++ b/sdk/python/src/openai_codex/__init__.py @@ -14,11 +14,11 @@ from .api import ( InputItem, LocalImageInput, MentionInput, - RunResult, SkillInput, TextInput, Thread, TurnHandle, + TurnResult, ) from .client import AppServerConfig from .errors import ( @@ -51,7 +51,7 @@ __all__ = [ "AsyncThread", "TurnHandle", "AsyncTurnHandle", - "RunResult", + "TurnResult", "Input", "InputItem", "TextInput", diff --git a/sdk/python/src/openai_codex/_run.py b/sdk/python/src/openai_codex/_run.py index 73ec362460..ac9fa5ed38 100644 --- a/sdk/python/src/openai_codex/_run.py +++ b/sdk/python/src/openai_codex/_run.py @@ -12,13 +12,20 @@ from .generated.v2_all import ( ThreadTokenUsageUpdatedNotification, Turn as AppServerTurn, TurnCompletedNotification, + TurnError, TurnStatus, ) from .models import Notification @dataclass(slots=True) -class RunResult: +class TurnResult: + id: str + status: TurnStatus + error: TurnError | None + started_at: int | None + completed_at: int | None + duration_ms: int | None final_response: str | None items: list[ThreadItem] usage: ThreadTokenUsage | None @@ -56,7 +63,7 @@ def _raise_for_failed_turn(turn: AppServerTurn) -> None: raise RuntimeError(f"turn failed with status {turn.status.value}") -def _collect_run_result(stream: Iterator[Notification], *, turn_id: str) -> RunResult: +def _collect_turn_result(stream: Iterator[Notification], *, turn_id: str) -> TurnResult: completed: TurnCompletedNotification | None = None items: list[ThreadItem] = [] usage: ThreadTokenUsage | None = None @@ -76,16 +83,23 @@ def _collect_run_result(stream: Iterator[Notification], *, turn_id: str) -> RunR raise RuntimeError("turn completed event not received") _raise_for_failed_turn(completed.turn) - return RunResult( + turn = completed.turn + return TurnResult( + id=turn.id, + status=turn.status, + error=turn.error, + started_at=turn.started_at, + completed_at=turn.completed_at, + duration_ms=turn.duration_ms, final_response=_final_assistant_response_from_items(items), items=items, usage=usage, ) -async def _collect_async_run_result( +async def _collect_async_turn_result( stream: AsyncIterator[Notification], *, turn_id: str -) -> RunResult: +) -> TurnResult: completed: TurnCompletedNotification | None = None items: list[ThreadItem] = [] usage: ThreadTokenUsage | None = None @@ -105,7 +119,14 @@ async def _collect_async_run_result( raise RuntimeError("turn completed event not received") _raise_for_failed_turn(completed.turn) - return RunResult( + turn = completed.turn + return TurnResult( + id=turn.id, + status=turn.status, + error=turn.error, + started_at=turn.started_at, + completed_at=turn.completed_at, + duration_ms=turn.duration_ms, final_response=_final_assistant_response_from_items(items), items=items, usage=usage, diff --git a/sdk/python/src/openai_codex/api.py b/sdk/python/src/openai_codex/api.py index fe9b5bc67f..347f95eeae 100644 --- a/sdk/python/src/openai_codex/api.py +++ b/sdk/python/src/openai_codex/api.py @@ -33,9 +33,9 @@ from ._login import ( start_device_code_login, ) from ._run import ( - RunResult, - _collect_async_run_result, - _collect_run_result, + TurnResult, + _collect_async_turn_result, + _collect_turn_result, ) from .async_client import AsyncAppServerClient from .client import AppServerClient, AppServerConfig @@ -65,7 +65,6 @@ from .generated.v2_all import ( ThreadSourceKind, ThreadStartParams, ThreadStartSource, - Turn as AppServerTurn, TurnCompletedNotification, TurnInterruptResponse, TurnStartParams, @@ -533,7 +532,7 @@ class Thread: sandbox_policy: SandboxPolicy | None = None, service_tier: str | None = None, summary: ReasoningSummary | None = None, - ) -> RunResult: + ) -> TurnResult: turn = self.turn( _normalize_run_input(input), approval_mode=approval_mode, @@ -548,7 +547,7 @@ class Thread: ) stream = turn.stream() try: - return _collect_run_result(stream, turn_id=turn.id) + return _collect_turn_result(stream, turn_id=turn.id) finally: stream.close() @@ -616,7 +615,7 @@ class AsyncThread: sandbox_policy: SandboxPolicy | None = None, service_tier: str | None = None, summary: ReasoningSummary | None = None, - ) -> RunResult: + ) -> TurnResult: turn = await self.turn( _normalize_run_input(input), approval_mode=approval_mode, @@ -631,7 +630,7 @@ class AsyncThread: ) stream = turn.stream() try: - return await _collect_async_run_result(stream, turn_id=turn.id) + return await _collect_async_turn_result(stream, turn_id=turn.id) finally: await stream.aclose() @@ -717,21 +716,13 @@ class TurnHandle: finally: self._client.unregister_turn_notifications(self.id) - def run(self) -> AppServerTurn: - completed: TurnCompletedNotification | None = None + def run(self) -> TurnResult: stream = self.stream() try: - for event in stream: - payload = event.payload - if isinstance(payload, TurnCompletedNotification) and payload.turn.id == self.id: - completed = payload + return _collect_turn_result(stream, turn_id=self.id) finally: stream.close() - if completed is None: - raise RuntimeError("turn completed event not received") - return completed.turn - @dataclass(slots=True) class AsyncTurnHandle: @@ -768,17 +759,9 @@ class AsyncTurnHandle: finally: self._codex._client.unregister_turn_notifications(self.id) - async def run(self) -> AppServerTurn: - completed: TurnCompletedNotification | None = None + async def run(self) -> TurnResult: stream = self.stream() try: - async for event in stream: - payload = event.payload - if isinstance(payload, TurnCompletedNotification) and payload.turn.id == self.id: - completed = payload + return await _collect_async_turn_result(stream, turn_id=self.id) finally: await stream.aclose() - - if completed is None: - raise RuntimeError("turn completed event not received") - return completed.turn diff --git a/sdk/python/src/openai_codex/types.py b/sdk/python/src/openai_codex/types.py index 26ffdbb3ef..f5bbbc86c3 100644 --- a/sdk/python/src/openai_codex/types.py +++ b/sdk/python/src/openai_codex/types.py @@ -33,6 +33,7 @@ from .generated.v2_all import ( ThreadTokenUsageUpdatedNotification, Turn, TurnCompletedNotification, + TurnError, TurnInterruptResponse, TurnStatus, TurnSteerResponse, @@ -73,6 +74,7 @@ __all__ = [ "ThreadTokenUsageUpdatedNotification", "Turn", "TurnCompletedNotification", + "TurnError", "TurnInterruptResponse", "TurnStatus", "TurnSteerResponse", diff --git a/sdk/python/tests/app_server_helpers.py b/sdk/python/tests/app_server_helpers.py index 10db00901c..af96a02e80 100644 --- a/sdk/python/tests/app_server_helpers.py +++ b/sdk/python/tests/app_server_helpers.py @@ -111,7 +111,7 @@ def agent_message_texts(events: list[Notification]) -> list[str]: def agent_message_texts_from_items(items: Iterable[Any]) -> list[str]: - """Extract agent-message text from completed run result items.""" + """Extract agent-message text from completed turn result items.""" texts: list[str] = [] for item in items: root = item.root diff --git a/sdk/python/tests/test_app_server_lifecycle.py b/sdk/python/tests/test_app_server_lifecycle.py index 77ba269450..9636c708c2 100644 --- a/sdk/python/tests/test_app_server_lifecycle.py +++ b/sdk/python/tests/test_app_server_lifecycle.py @@ -157,7 +157,7 @@ def test_async_lifecycle_methods_round_trip(tmp_path) -> None: async with AsyncCodex(config=harness.app_server_config()) as codex: thread = await codex.thread_start() - run_result = await thread.run("materialize async thread") + turn_result = await thread.run("materialize async thread") await thread.set_name("async lifecycle") named = await thread.read() resumed = await codex.thread_resume(thread.id) @@ -166,14 +166,14 @@ def test_async_lifecycle_methods_round_trip(tmp_path) -> None: unarchived = await codex.thread_unarchive(thread.id) assert { - "run_final_response": run_result.final_response, + "turn_final_response": turn_result.final_response, "named_thread": named.thread.name, "resumed_id": resumed.id, "forked_is_distinct": forked.id != thread.id, "archive_response": archive_response.model_dump(by_alias=True, mode="json"), "unarchived_id": unarchived.id, } == { - "run_final_response": "async materialized", + "turn_final_response": "async materialized", "named_thread": "async lifecycle", "resumed_id": thread.id, "forked_is_distinct": True, @@ -253,19 +253,19 @@ def test_compact_rpc_hits_mock_responses(tmp_path) -> None: with Codex(config=harness.app_server_config()) as codex: thread = codex.thread_start() - run_result = thread.run("create history") + turn_result = thread.run("create history") compact_response = thread.compact() requests = harness.responses.wait_for_requests(2) assert { - "run_final_response": run_result.final_response, + "turn_final_response": turn_result.final_response, "compact_response": compact_response.model_dump( by_alias=True, mode="json", ), "request_kinds": [request_kind(request.path) for request in requests], } == { - "run_final_response": "history", + "turn_final_response": "history", "compact_response": {}, "request_kinds": ["responses", "responses"], } diff --git a/sdk/python/tests/test_app_server_run.py b/sdk/python/tests/test_app_server_run.py index a0ac3eca16..3f741e6ec6 100644 --- a/sdk/python/tests/test_app_server_run.py +++ b/sdk/python/tests/test_app_server_run.py @@ -145,8 +145,8 @@ def test_async_thread_run_uses_mock_responses( asyncio.run(scenario()) -def test_sync_run_result_uses_last_unknown_phase_message(tmp_path) -> None: - """RunResult should use the last unknown-phase agent message as final text.""" +def test_sync_turn_result_uses_last_unknown_phase_message(tmp_path) -> None: + """TurnResult should use the last unknown-phase agent message as final text.""" with AppServerHarness(tmp_path) as harness: harness.responses.enqueue_sse( sse( @@ -171,8 +171,8 @@ def test_sync_run_result_uses_last_unknown_phase_message(tmp_path) -> None: } -def test_sync_run_result_preserves_empty_last_message(tmp_path) -> None: - """RunResult should preserve an empty final agent message instead of skipping it.""" +def test_sync_turn_result_preserves_empty_last_message(tmp_path) -> None: + """TurnResult should preserve an empty final agent message instead of skipping it.""" with AppServerHarness(tmp_path) as harness: harness.responses.enqueue_sse( sse( @@ -197,8 +197,8 @@ def test_sync_run_result_preserves_empty_last_message(tmp_path) -> None: } -def test_sync_run_result_does_not_promote_commentary_only_to_final(tmp_path) -> None: - """RunResult final_response should stay unset when app-server marks only commentary.""" +def test_sync_turn_result_does_not_promote_commentary_only_to_final(tmp_path) -> None: + """TurnResult final_response should stay unset when app-server marks only commentary.""" with AppServerHarness(tmp_path) as harness: harness.responses.enqueue_sse( sse( @@ -226,8 +226,8 @@ def test_sync_run_result_does_not_promote_commentary_only_to_final(tmp_path) -> } -def test_async_run_result_uses_last_unknown_phase_message(tmp_path) -> None: - """Async RunResult should use the last unknown-phase agent message.""" +def test_async_turn_result_uses_last_unknown_phase_message(tmp_path) -> None: + """Async TurnResult should use the last unknown-phase agent message.""" async def scenario() -> None: """Run one async result-mapping case against a pinned app-server.""" @@ -263,10 +263,10 @@ def test_async_run_result_uses_last_unknown_phase_message(tmp_path) -> None: asyncio.run(scenario()) -def test_async_run_result_does_not_promote_commentary_only_to_final( +def test_async_turn_result_does_not_promote_commentary_only_to_final( tmp_path, ) -> None: - """Async RunResult final_response should stay unset for commentary-only output.""" + """Async TurnResult final_response should stay unset for commentary-only output.""" async def scenario() -> None: """Run one async commentary mapping case against a pinned app-server.""" @@ -318,7 +318,7 @@ def test_thread_run_raises_when_real_app_server_reports_failed_turn(tmp_path) -> def test_final_answer_phase_survives_real_app_server_mapping(tmp_path) -> None: - """RunResult should use the final-answer item emitted by app-server.""" + """TurnResult should use the final-answer item emitted by app-server.""" with AppServerHarness(tmp_path) as harness: harness.responses.enqueue_sse( sse( diff --git a/sdk/python/tests/test_app_server_streaming.py b/sdk/python/tests/test_app_server_streaming.py index ae217adb6a..02d089f2f1 100644 --- a/sdk/python/tests/test_app_server_streaming.py +++ b/sdk/python/tests/test_app_server_streaming.py @@ -5,6 +5,7 @@ import asyncio from app_server_harness import AppServerHarness from app_server_helpers import ( agent_message_texts, + agent_message_texts_from_items, next_async_delta, next_sync_delta, streaming_response, @@ -48,7 +49,7 @@ def test_sync_stream_routes_text_deltas_and_completion(tmp_path) -> None: def test_turn_run_returns_completed_turn(tmp_path) -> None: - """TurnHandle.run should wait for the app-server completion notification.""" + """TurnHandle.run should collect output and completion metadata.""" with AppServerHarness(tmp_path) as harness: harness.responses.enqueue_assistant_message("turn complete", response_id="turn-run-1") @@ -60,11 +61,13 @@ def test_turn_run_returns_completed_turn(tmp_path) -> None: assert { "turn_id": completed.id, "status": completed.status, - "items": completed.items, + "agent_messages": agent_message_texts_from_items(completed.items), + "final_response": completed.final_response, } == { "turn_id": turn.id, "status": TurnStatus.completed, - "items": [], + "agent_messages": ["turn complete"], + "final_response": "turn complete", } diff --git a/sdk/python/tests/test_public_api_signatures.py b/sdk/python/tests/test_public_api_signatures.py index 04eb18e4cb..c7a00d3897 100644 --- a/sdk/python/tests/test_public_api_signatures.py +++ b/sdk/python/tests/test_public_api_signatures.py @@ -14,9 +14,11 @@ from openai_codex import ( AppServerConfig, AsyncCodex, AsyncThread, + AsyncTurnHandle, Codex, - RunResult, Thread, + TurnHandle, + TurnResult, ) from openai_codex._initialize_metadata import validate_initialize_metadata from openai_codex.types import InitializeResponse @@ -35,7 +37,7 @@ EXPECTED_ROOT_EXPORTS = [ "AsyncThread", "TurnHandle", "AsyncTurnHandle", - "RunResult", + "TurnResult", "Input", "InputItem", "TextInput", @@ -92,6 +94,7 @@ EXPECTED_TYPES_EXPORTS = [ "ThreadTokenUsageUpdatedNotification", "Turn", "TurnCompletedNotification", + "TurnError", "TurnInterruptResponse", "TurnStatus", "TurnSteerResponse", @@ -128,9 +131,39 @@ def test_root_exports_app_server_config() -> None: assert AppServerConfig.__name__ == "AppServerConfig" -def test_root_exports_run_result() -> None: - """The root package should expose the common-case run result wrapper.""" - assert RunResult.__name__ == "RunResult" +def test_root_exports_turn_result() -> None: + """The root package should expose the collected turn result wrapper.""" + assert { + "name": TurnResult.__name__, + "fields": list(TurnResult.__dataclass_fields__), + } == { + "name": "TurnResult", + "fields": [ + "id", + "status", + "error", + "started_at", + "completed_at", + "duration_ms", + "final_response", + "items", + "usage", + ], + } + + +def test_turn_run_methods_return_turn_result() -> None: + """Both convenience and handle-based run APIs return the same result shape.""" + funcs = [ + Thread.run, + TurnHandle.run, + AsyncThread.run, + AsyncTurnHandle.run, + ] + + assert {fn: inspect.signature(fn).return_annotation for fn in funcs} == dict.fromkeys( + funcs, "TurnResult" + ) def test_root_exports_approval_mode() -> None: diff --git a/sdk/python/tests/test_real_app_server_integration.py b/sdk/python/tests/test_real_app_server_integration.py index 0e688ff3c6..08f07c986c 100644 --- a/sdk/python/tests/test_real_app_server_integration.py +++ b/sdk/python/tests/test_real_app_server_integration.py @@ -135,7 +135,7 @@ def _run_python( ) -def _runtime_compatibility_hint( +def _runtime_schema_hint( runtime_env: PreparedRuntimeEnv, *, stdout: str, @@ -144,7 +144,7 @@ def _runtime_compatibility_hint( combined = f"{stdout}\n{stderr}" if "ThreadStartResponse" in combined and "approvalsReviewer" in combined: return ( - "\nCompatibility hint:\n" + "\nSchema hint:\n" f"Pinned runtime {runtime_env.runtime_version} returned a thread/start payload " "that is older than the current SDK schema and is missing " "`approvalsReviewer`. Bump `sdk/python/_runtime_setup.py` to a matching " @@ -165,7 +165,7 @@ def _run_json_python( "Python snippet failed.\n" f"STDOUT:\n{result.stdout}\n" f"STDERR:\n{result.stderr}" - f"{_runtime_compatibility_hint(runtime_env, stdout=result.stdout, stderr=result.stderr)}" + f"{_runtime_schema_hint(runtime_env, stdout=result.stdout, stderr=result.stderr)}" ) return json.loads(result.stdout) @@ -242,17 +242,12 @@ def test_real_thread_and_turn_start_smoke(runtime_env: PreparedRuntimeEnv) -> No config={"model_reasoning_effort": "high"}, ) result = thread.turn(TextInput("hello")).run() - persisted = thread.read(include_turns=True) - persisted_turn = next( - (turn for turn in persisted.thread.turns or [] if turn.id == result.id), - None, - ) print(json.dumps({ "thread_id": thread.id, "turn_id": result.id, "status": result.status.value, - "items_count": len(result.items or []), - "persisted_items_count": 0 if persisted_turn is None else len(persisted_turn.items or []), + "items_count": len(result.items), + "final_response_is_text": isinstance(result.final_response, str) and bool(result.final_response.strip()), })) """ ), @@ -261,8 +256,8 @@ def test_real_thread_and_turn_start_smoke(runtime_env: PreparedRuntimeEnv) -> No assert isinstance(data["thread_id"], str) and data["thread_id"].strip() assert isinstance(data["turn_id"], str) and data["turn_id"].strip() assert data["status"] == "completed" - assert isinstance(data["items_count"], int) - assert isinstance(data["persisted_items_count"], int) + assert data["items_count"] > 0 + assert data["final_response_is_text"] is True def test_real_thread_run_convenience_smoke(runtime_env: PreparedRuntimeEnv) -> None: @@ -345,17 +340,12 @@ def test_real_async_thread_turn_usage_and_ids_smoke( config={"model_reasoning_effort": "high"}, ) result = await (await thread.turn(TextInput("say ok"))).run() - persisted = await thread.read(include_turns=True) - persisted_turn = next( - (turn for turn in persisted.thread.turns or [] if turn.id == result.id), - None, - ) print(json.dumps({ "thread_id": thread.id, "turn_id": result.id, "status": result.status.value, - "items_count": len(result.items or []), - "persisted_items_count": 0 if persisted_turn is None else len(persisted_turn.items or []), + "items_count": len(result.items), + "final_response_is_text": isinstance(result.final_response, str) and bool(result.final_response.strip()), })) asyncio.run(main()) @@ -366,8 +356,8 @@ def test_real_async_thread_turn_usage_and_ids_smoke( assert isinstance(data["thread_id"], str) and data["thread_id"].strip() assert isinstance(data["turn_id"], str) and data["turn_id"].strip() assert data["status"] == "completed" - assert isinstance(data["items_count"], int) - assert isinstance(data["persisted_items_count"], int) + assert data["items_count"] > 0 + assert data["final_response_is_text"] is True def test_real_async_thread_run_convenience_smoke( @@ -531,7 +521,7 @@ def test_real_examples_run_and_assert( f"Example failed: {folder}/{script}\n" f"STDOUT:\n{result.stdout}\n" f"STDERR:\n{result.stderr}" - f"{_runtime_compatibility_hint(runtime_env, stdout=result.stdout, stderr=result.stderr)}" + f"{_runtime_schema_hint(runtime_env, stdout=result.stdout, stderr=result.stderr)}" ) out = result.stdout @@ -541,7 +531,7 @@ def test_real_examples_run_and_assert( assert "Server: unknown" not in out elif folder == "02_turn_run": assert "thread_id:" in out and "turn_id:" in out and "status:" in out - assert "persisted.items.count:" in out + assert "items.count:" in out elif folder == "03_turn_stream_events": assert "stream.completed:" in out assert "assistant>" in out From 545ede569cff13d0aabc782ebd8cdaa6d5cb86ab Mon Sep 17 00:00:00 2001 From: jif-oai Date: Sun, 17 May 2026 15:27:43 +0200 Subject: [PATCH 2/6] Make multi-agent v2 tool namespace configurable (#23147) ## Summary - Add `features.multi_agent_v2.tool_namespace` with config/schema validation for Responses-compatible namespace values. - Thread the resolved namespace into `ToolsConfig` for normal turns and review turns. - Wrap MultiAgentV2 tool specs and registry names in the configured namespace when namespace tools are supported, while falling back to the plain tool names when they are not. ## Validation - `just fmt` - `just write-config-schema` - `cargo test -p codex-features multi_agent_v2_feature_config -- --nocapture` - `cargo test -p codex-core test_build_specs_multi_agent_v2 -- --nocapture` - `cargo test -p codex-core multi_agent_v2_config -- --nocapture` - `cargo test -p codex-core multi_agent_v2_rejects_invalid_tool_namespace -- --nocapture` - `cargo test -p codex-tools` - `git diff --check` --- codex-rs/core/config.schema.json | 6 + codex-rs/core/src/config/config_tests.rs | 47 +++++++ codex-rs/core/src/config/mod.rs | 72 ++++++++++ codex-rs/core/src/session/review.rs | 1 + codex-rs/core/src/session/turn_context.rs | 2 + codex-rs/core/src/tools/registry.rs | 4 - codex-rs/core/src/tools/spec_plan.rs | 133 +++++++++++++++--- .../core/src/tools/spec_plan_model_tests.rs | 64 +++++++++ codex-rs/core/src/tools/spec_plan_tests.rs | 88 ++++++++++++ codex-rs/features/src/feature_configs.rs | 3 + codex-rs/features/src/tests.rs | 3 + codex-rs/tools/src/tool_config.rs | 10 ++ 12 files changed, 409 insertions(+), 24 deletions(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index de3632d629..07f49a3cd6 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1516,6 +1516,12 @@ "subagent_usage_hint_text": { "type": "string" }, + "tool_namespace": { + "maxLength": 64, + "minLength": 1, + "pattern": "^[a-zA-Z0-9_-]+$", + "type": "string" + }, "usage_hint_enabled": { "type": "boolean" }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index ce7a36d93f..21d291b309 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -10112,6 +10112,7 @@ usage_hint_enabled = false usage_hint_text = "Custom delegation guidance." root_agent_usage_hint_text = "Root guidance." subagent_usage_hint_text = "Subagent guidance." +tool_namespace = "agents" hide_spawn_agent_metadata = true non_code_mode_only = true "#, @@ -10142,6 +10143,10 @@ non_code_mode_only = true config.multi_agent_v2.subagent_usage_hint_text.as_deref(), Some("Subagent guidance.") ); + assert_eq!( + config.multi_agent_v2.tool_namespace.as_deref(), + Some("agents") + ); assert!(config.multi_agent_v2.hide_spawn_agent_metadata); assert!(config.multi_agent_v2.non_code_mode_only); @@ -10164,6 +10169,7 @@ usage_hint_enabled = true usage_hint_text = "base hint" root_agent_usage_hint_text = "base root hint" subagent_usage_hint_text = "base subagent hint" +tool_namespace = "base_agents" hide_spawn_agent_metadata = true non_code_mode_only = false @@ -10176,6 +10182,7 @@ usage_hint_enabled = false usage_hint_text = "profile hint" root_agent_usage_hint_text = "profile root hint" subagent_usage_hint_text = "profile subagent hint" +tool_namespace = "profile_agents" hide_spawn_agent_metadata = false non_code_mode_only = true "#, @@ -10204,6 +10211,10 @@ non_code_mode_only = true config.multi_agent_v2.subagent_usage_hint_text.as_deref(), Some("profile subagent hint") ); + assert_eq!( + config.multi_agent_v2.tool_namespace.as_deref(), + Some("profile_agents") + ); assert!(!config.multi_agent_v2.hide_spawn_agent_metadata); assert!(config.multi_agent_v2.non_code_mode_only); @@ -10464,6 +10475,42 @@ default_wait_timeout_ms = 2500 Ok(()) } +#[tokio::test] +async fn multi_agent_v2_rejects_invalid_tool_namespace() -> std::io::Result<()> { + for (namespace, expected_message) in [ + ( + "bad namespace", + "features.multi_agent_v2.tool_namespace must match ^[a-zA-Z0-9_-]+$", + ), + ( + "functions", + "features.multi_agent_v2.tool_namespace uses a reserved namespace: functions", + ), + ] { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + format!( + r#"[features.multi_agent_v2] +enabled = true +tool_namespace = "{namespace}" +"# + ), + )?; + + let err = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .build() + .await + .expect_err("invalid multi_agent_v2 tool namespace should fail"); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!(err.to_string(), expected_message); + } + + Ok(()) +} + #[tokio::test] async fn multi_agent_v2_session_thread_cap_one_disallows_subagents() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 18158671ec..71a55b98dc 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -953,6 +953,7 @@ pub struct MultiAgentV2Config { pub usage_hint_text: Option, pub root_agent_usage_hint_text: Option, pub subagent_usage_hint_text: Option, + pub tool_namespace: Option, pub hide_spawn_agent_metadata: bool, pub non_code_mode_only: bool, } @@ -969,6 +970,7 @@ impl Default for MultiAgentV2Config { usage_hint_text: None, root_agent_usage_hint_text: None, subagent_usage_hint_text: None, + tool_namespace: None, hide_spawn_agent_metadata: false, non_code_mode_only: false, } @@ -2178,6 +2180,11 @@ fn resolve_multi_agent_v2_config( .or_else(|| base.and_then(|config| config.subagent_usage_hint_text.as_ref())) .cloned() .or(default.subagent_usage_hint_text); + let tool_namespace = profile + .and_then(|config| config.tool_namespace.as_ref()) + .or_else(|| base.and_then(|config| config.tool_namespace.as_ref())) + .cloned() + .or(default.tool_namespace); let hide_spawn_agent_metadata = profile .and_then(|config| config.hide_spawn_agent_metadata) .or_else(|| base.and_then(|config| config.hide_spawn_agent_metadata)) @@ -2196,6 +2203,7 @@ fn resolve_multi_agent_v2_config( usage_hint_text, root_agent_usage_hint_text, subagent_usage_hint_text, + tool_namespace, hide_spawn_agent_metadata, non_code_mode_only, } @@ -2290,6 +2298,69 @@ fn validate_multi_agent_v2_wait_timeout(label: &str, value: i64) -> std::io::Res Ok(()) } +fn validate_multi_agent_v2_tool_namespace(namespace: Option<&str>) -> std::io::Result<()> { + const LABEL: &str = "features.multi_agent_v2.tool_namespace"; + const MAX_LEN: usize = 64; + const RESERVED_RESPONSES_NAMESPACES: &[&str] = &[ + "api_tool", + "browser", + "computer", + "container", + "file_search", + "functions", + "image_gen", + "multi_tool_use", + "python", + "python_user_visible", + "submodel_delegator", + "terminal", + "tool_search", + "web", + ]; + + let Some(namespace) = namespace else { + return Ok(()); + }; + if namespace.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{LABEL} must not be empty"), + )); + } + if namespace.trim() != namespace { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{LABEL} must not have leading or trailing whitespace"), + )); + } + if !namespace + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{LABEL} must match ^[a-zA-Z0-9_-]+$"), + )); + } + if namespace.chars().count() > MAX_LEN { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{LABEL} must be at most {MAX_LEN} characters"), + )); + } + if namespace == "mcp" + || namespace.starts_with("mcp__") + || RESERVED_RESPONSES_NAMESPACES.contains(&namespace) + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{LABEL} uses a reserved namespace: {namespace}"), + )); + } + + Ok(()) +} + impl Config { #[cfg(test)] async fn load_from_base_config_with_overrides( @@ -2911,6 +2982,7 @@ impl Config { "features.multi_agent_v2.default_wait_timeout_ms must be at most features.multi_agent_v2.max_wait_timeout_ms", )); } + validate_multi_agent_v2_tool_namespace(multi_agent_v2.tool_namespace.as_deref())?; let agent_max_threads_from_config = cfg.agents.as_ref().and_then(|agents| agents.max_threads); let agent_max_threads = if features.enabled(Feature::MultiAgentV2) { if agent_max_threads_from_config.is_some() { diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index 497cddd912..7850e8f8b6 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -56,6 +56,7 @@ pub(super) async fn spawn_review_thread( .with_spawn_agent_usage_hint(config.multi_agent_v2.usage_hint_enabled) .with_spawn_agent_usage_hint_text(config.multi_agent_v2.usage_hint_text.clone()) .with_hide_spawn_agent_metadata(config.multi_agent_v2.hide_spawn_agent_metadata) + .with_multi_agent_v2_tool_namespace(config.multi_agent_v2.tool_namespace.clone()) .with_multi_agent_v2_non_code_mode_only(config.multi_agent_v2.non_code_mode_only) .with_goal_tools_allowed(goal_tools_supported) .with_max_concurrent_threads_per_session(config.agent_max_threads) diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 92c7893d5e..8f0ce2d5fd 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -220,6 +220,7 @@ impl TurnContext { .with_spawn_agent_usage_hint(config.multi_agent_v2.usage_hint_enabled) .with_spawn_agent_usage_hint_text(config.multi_agent_v2.usage_hint_text.clone()) .with_hide_spawn_agent_metadata(config.multi_agent_v2.hide_spawn_agent_metadata) + .with_multi_agent_v2_tool_namespace(config.multi_agent_v2.tool_namespace.clone()) .with_multi_agent_v2_non_code_mode_only(config.multi_agent_v2.non_code_mode_only) .with_goal_tools_allowed(self.tools_config.goal_tools) .with_max_concurrent_threads_per_session( @@ -539,6 +540,7 @@ impl Session { .with_spawn_agent_usage_hint(per_turn_config.multi_agent_v2.usage_hint_enabled) .with_spawn_agent_usage_hint_text(per_turn_config.multi_agent_v2.usage_hint_text.clone()) .with_hide_spawn_agent_metadata(per_turn_config.multi_agent_v2.hide_spawn_agent_metadata) + .with_multi_agent_v2_tool_namespace(per_turn_config.multi_agent_v2.tool_namespace.clone()) .with_multi_agent_v2_non_code_mode_only(per_turn_config.multi_agent_v2.non_code_mode_only) .with_goal_tools_allowed(goal_tools_supported) .with_max_concurrent_threads_per_session( diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 1cf0e00623..96b686a83a 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -281,10 +281,6 @@ impl ToolRegistry { self.tools.get(name).map(Arc::clone) } - pub(crate) fn tool_exposure(&self, name: &ToolName) -> Option { - self.tools.get(name).map(|tool| tool.exposure()) - } - #[cfg(test)] pub(crate) fn has_tool(&self, name: &ToolName) -> bool { self.tool(name).is_some() diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index be5e6741d4..3f355d2509 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -2,6 +2,7 @@ use crate::config::DEFAULT_MULTI_AGENT_V2_DEFAULT_WAIT_TIMEOUT_MS; use crate::config::DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS; use crate::config::DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS; use crate::tools::code_mode::execute_spec::create_code_mode_tool; +use crate::tools::context::ToolInvocation; use crate::tools::handlers::ApplyPatchHandler; use crate::tools::handlers::CodeModeExecuteHandler; use crate::tools::handlers::CodeModeWaitHandler; @@ -58,12 +59,14 @@ use codex_mcp::ToolInfo; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::openai_models::ConfigShellToolType; use codex_tools::DiscoverableTool; +use codex_tools::ResponsesApiNamespace; use codex_tools::ResponsesApiNamespaceTool; use codex_tools::TOOL_SEARCH_TOOL_NAME; use codex_tools::ToolCall as ExtensionToolCall; use codex_tools::ToolEnvironmentMode; use codex_tools::ToolExecutor; use codex_tools::ToolName; +use codex_tools::ToolOutput; use codex_tools::ToolSpec; use codex_tools::ToolsConfig; use codex_tools::collect_code_mode_exec_prompt_tool_definitions; @@ -73,6 +76,8 @@ use std::collections::HashSet; use std::sync::Arc; use tracing::warn; +const MULTI_AGENT_V2_NAMESPACE_DESCRIPTION: &str = "Tools for spawning and managing sub-agents."; + #[derive(Clone, Copy)] struct ToolRegistryBuildParams<'a> { mcp_tools: Option<&'a [ToolInfo]>, @@ -127,22 +132,29 @@ fn build_model_visible_specs_and_registry( let mut specs = Vec::new(); let mut seen_tool_names = HashSet::new(); for executor in &executors { - if !seen_tool_names.insert(executor.tool_name()) { + let tool_name = executor.tool_name(); + if !seen_tool_names.insert(tool_name.clone()) { continue; } - if executor.exposure().is_direct() + let exposure = executor.exposure(); + if exposure.is_direct() + && !is_hidden_by_code_mode_only(config, &tool_name, exposure) && let Some(spec) = executor.spec() { - specs.push(spec_for_model_request(config, executor.exposure(), spec)); + specs.push(spec_for_model_request(config, exposure, spec)); + } + } + for spec in hosted_specs { + if !is_hidden_by_code_mode_only(config, &ToolName::plain(spec.name()), ToolExposure::Direct) + { + specs.push(spec); } } - specs.extend(hosted_specs); let registry = ToolRegistry::from_tools(executors); let model_visible_specs = merge_into_namespaces(specs) .into_iter() .filter(|spec| config.namespace_tools || !matches!(spec, ToolSpec::Namespace(_))) - .filter(|spec| !is_hidden_by_code_mode_only(config, ®istry, spec)) .collect(); (model_visible_specs, registry) @@ -210,17 +222,14 @@ fn agent_type_description(config: &ToolsConfig, default_agent_type_description: fn is_hidden_by_code_mode_only( config: &ToolsConfig, - registry: &ToolRegistry, - spec: &ToolSpec, + tool_name: &ToolName, + exposure: ToolExposure, ) -> bool { - if !config.code_mode_only_enabled || !codex_code_mode::is_code_mode_nested_tool(spec.name()) { - return false; - } - - let exposure = registry - .tool_exposure(&ToolName::plain(spec.name())) - .unwrap_or(ToolExposure::Direct); - exposure != ToolExposure::DirectModelOnly + config.code_mode_only_enabled + && exposure != ToolExposure::DirectModelOnly + && codex_code_mode::is_code_mode_nested_tool(&codex_tools::code_mode_name_for_tool_name( + tool_name, + )) } fn build_code_mode_executors( @@ -442,6 +451,10 @@ fn collect_tool_executors( } else { ToolExposure::Direct }; + let tool_namespace = config + .namespace_tools + .then_some(config.multi_agent_v2_tool_namespace.as_deref()) + .flatten(); let agent_type_description = agent_type_description(config, params.default_agent_type_description); executors.push(multi_agent_v2_handler( @@ -454,15 +467,33 @@ fn collect_tool_executors( max_concurrent_threads_per_session: config.max_concurrent_threads_per_session, }), exposure, + tool_namespace, + )); + executors.push(multi_agent_v2_handler( + SendMessageHandlerV2, + exposure, + tool_namespace, + )); + executors.push(multi_agent_v2_handler( + FollowupTaskHandlerV2, + exposure, + tool_namespace, )); - executors.push(multi_agent_v2_handler(SendMessageHandlerV2, exposure)); - executors.push(multi_agent_v2_handler(FollowupTaskHandlerV2, exposure)); executors.push(multi_agent_v2_handler( WaitAgentHandlerV2::new(params.wait_agent_timeouts), exposure, + tool_namespace, + )); + executors.push(multi_agent_v2_handler( + CloseAgentHandlerV2, + exposure, + tool_namespace, + )); + executors.push(multi_agent_v2_handler( + ListAgentsHandlerV2, + exposure, + tool_namespace, )); - executors.push(multi_agent_v2_handler(CloseAgentHandlerV2, exposure)); - executors.push(multi_agent_v2_handler(ListAgentsHandlerV2, exposure)); } else { let agent_type_description = agent_type_description(config, params.default_agent_type_description); @@ -592,8 +623,70 @@ fn append_extension_tool_executors( fn multi_agent_v2_handler( handler: impl CoreToolRuntime + 'static, exposure: ToolExposure, + namespace: Option<&str>, ) -> Arc { - override_tool_exposure(Arc::new(handler), exposure) + let handler: Arc = match namespace { + Some(namespace) => Arc::new(MultiAgentV2NamespaceOverride { + handler: Arc::new(handler), + namespace: namespace.to_string(), + }), + None => Arc::new(handler), + }; + override_tool_exposure(handler, exposure) +} + +struct MultiAgentV2NamespaceOverride { + handler: Arc, + namespace: String, +} + +#[async_trait::async_trait] +impl ToolExecutor for MultiAgentV2NamespaceOverride { + fn tool_name(&self) -> ToolName { + ToolName::namespaced(self.namespace.clone(), self.handler.tool_name().name) + } + + fn spec(&self) -> Option { + match self.handler.spec()? { + ToolSpec::Function(tool) => Some(ToolSpec::Namespace(ResponsesApiNamespace { + name: self.namespace.clone(), + description: MULTI_AGENT_V2_NAMESPACE_DESCRIPTION.to_string(), + tools: vec![ResponsesApiNamespaceTool::Function(tool)], + })), + spec => Some(spec), + } + } + + fn exposure(&self) -> ToolExposure { + self.handler.exposure() + } + + fn supports_parallel_tool_calls(&self) -> bool { + self.handler.supports_parallel_tool_calls() + } + + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, codex_tools::FunctionCallError> { + self.handler.handle(invocation).await + } +} + +impl CoreToolRuntime for MultiAgentV2NamespaceOverride { + fn matches_kind(&self, payload: &crate::tools::context::ToolPayload) -> bool { + self.handler.matches_kind(payload) + } + + fn search_info(&self) -> Option { + self.handler.search_info() + } + + fn create_diff_consumer( + &self, + ) -> Option> { + self.handler.create_diff_consumer() + } } fn compare_code_mode_tools( diff --git a/codex-rs/core/src/tools/spec_plan_model_tests.rs b/codex-rs/core/src/tools/spec_plan_model_tests.rs index dcc1be30ed..86692e48df 100644 --- a/codex-rs/core/src/tools/spec_plan_model_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_model_tests.rs @@ -1341,3 +1341,67 @@ async fn code_mode_only_can_expose_multi_agent_v2_as_normal_tools() { }; assert!(!spawn_agent.description.contains("exec tool declaration")); } + +#[tokio::test] +async fn code_mode_only_can_expose_namespaced_multi_agent_v2_as_normal_tools() { + let config = test_config().await; + let model_info = construct_model_info_offline("gpt-5.4", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::CodeMode); + features.enable(Feature::CodeModeOnly); + features.enable(Feature::MultiAgentV2); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + image_generation_tool_auth_allowed: true, + web_search_mode: Some(WebSearchMode::Live), + session_source: SessionSource::Cli, + permission_profile: &PermissionProfile::Disabled, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }) + .with_multi_agent_v2_tool_namespace(Some("agents".to_string())) + .with_multi_agent_v2_non_code_mode_only(/*multi_agent_v2_non_code_mode_only*/ true); + let router = ToolRouter::from_config( + &tools_config, + ToolRouterParams { + mcp_tools: None, + deferred_mcp_tools: None, + discoverable_tools: None, + extension_tool_executors: Vec::new(), + dynamic_tools: &[], + }, + ); + let model_visible_specs = router.model_visible_specs(); + let tool_names = model_visible_specs + .iter() + .map(ToolSpec::name) + .collect::>(); + + assert_eq!(tool_names, vec!["exec", "wait", "agents"]); + + let exec = find_tool(&model_visible_specs, "exec"); + let ToolSpec::Freeform(exec) = exec else { + panic!("exec should be a freeform tool"); + }; + assert!(!exec.description.contains("spawn_agent")); + assert!(!exec.description.contains("wait_agent")); + assert!( + !exec + .description + .contains("do not attempt to use any other tools directly") + ); + + for tool_name in [ + "spawn_agent", + "send_message", + "followup_task", + "wait_agent", + "close_agent", + "list_agents", + ] { + let tool = find_namespace_function_tool(&model_visible_specs, "agents", tool_name); + assert!(!tool.description.contains("exec tool declaration")); + } +} diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 333efe6f06..3442fbb5ff 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -577,6 +577,77 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() { assert_lacks_tool_name(&tools, "resume_agent"); } +#[test] +fn test_build_specs_multi_agent_v2_uses_configured_tool_namespace() { + let model_info = model_info(); + let mut features = Features::with_defaults(); + features.enable(Feature::MultiAgentV2); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + image_generation_tool_auth_allowed: true, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + permission_profile: &PermissionProfile::Disabled, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }) + .with_multi_agent_v2_tool_namespace(Some("agents".to_string())); + let (tools, registry) = build_specs( + &tools_config, + /*mcp_tools*/ None, + /*deferred_mcp_tools*/ None, + &[], + ); + + assert_contains_tool_names(&tools, &["agents"]); + for tool_name in [ + "spawn_agent", + "send_message", + "followup_task", + "wait_agent", + "close_agent", + "list_agents", + ] { + assert_lacks_tool_name(&tools, tool_name); + assert!(registry.has_tool(&ToolName::namespaced("agents", tool_name))); + assert!(!registry.has_tool(&ToolName::plain(tool_name))); + assert_namespace_contains_function(&tools, "agents", tool_name); + } +} + +#[test] +fn test_build_specs_multi_agent_v2_ignores_tool_namespace_without_namespace_support() { + let model_info = model_info(); + let mut features = Features::with_defaults(); + features.enable(Feature::MultiAgentV2); + let available_models = Vec::new(); + let mut tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + image_generation_tool_auth_allowed: true, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + permission_profile: &PermissionProfile::Disabled, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }) + .with_multi_agent_v2_tool_namespace(Some("agents".to_string())); + tools_config.namespace_tools = false; + let (tools, registry) = build_specs( + &tools_config, + /*mcp_tools*/ None, + /*deferred_mcp_tools*/ None, + &[], + ); + + assert_contains_tool_names(&tools, &["spawn_agent", "send_message", "list_agents"]); + assert_lacks_tool_name(&tools, "agents"); + assert!(registry.has_tool(&ToolName::plain("spawn_agent"))); + assert!(!registry.has_tool(&ToolName::namespaced("agents", "spawn_agent"))); +} + #[test] fn test_build_specs_multi_agent_v2_does_not_require_collab_feature() { let model_info = model_info(); @@ -2657,6 +2728,23 @@ fn find_tool<'a>(tools: &'a [ToolSpec], expected_name: &str) -> &'a ToolSpec { .unwrap_or_else(|| panic!("expected tool {expected_name}")) } +fn assert_namespace_contains_function( + tools: &[ToolSpec], + expected_namespace: &str, + expected_name: &str, +) { + let namespace_tool = find_tool(tools, expected_namespace); + let ToolSpec::Namespace(namespace) = namespace_tool else { + panic!("expected namespace tool {expected_namespace}"); + }; + assert!( + namespace.tools.iter().any(|tool| { + matches!(tool, ResponsesApiNamespaceTool::Function(tool) if tool.name == expected_name) + }), + "expected tool {expected_name} in namespace {expected_namespace}" + ); +} + fn assert_process_tool_environment_id( tools: &[ToolSpec], expected_name: &str, diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index 2b69c2d04d..bfd14237d5 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -30,6 +30,9 @@ pub struct MultiAgentV2ConfigToml { #[serde(skip_serializing_if = "Option::is_none")] pub subagent_usage_hint_text: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1, max = 64), regex(pattern = r"^[a-zA-Z0-9_-]+$"))] + pub tool_namespace: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub hide_spawn_agent_metadata: Option, #[serde(skip_serializing_if = "Option::is_none")] pub non_code_mode_only: Option, diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index bd55ef8e7d..f4aa3982b9 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -528,6 +528,7 @@ usage_hint_enabled = false usage_hint_text = "Custom delegation guidance." root_agent_usage_hint_text = "Root guidance." subagent_usage_hint_text = "Subagent guidance." +tool_namespace = "agents" hide_spawn_agent_metadata = true non_code_mode_only = true "#, @@ -550,6 +551,7 @@ non_code_mode_only = true usage_hint_text: Some("Custom delegation guidance.".to_string()), root_agent_usage_hint_text: Some("Root guidance.".to_string()), subagent_usage_hint_text: Some("Subagent guidance.".to_string()), + tool_namespace: Some("agents".to_string()), hide_spawn_agent_metadata: Some(true), non_code_mode_only: Some(true), })) @@ -588,6 +590,7 @@ usage_hint_enabled = false usage_hint_text: None, root_agent_usage_hint_text: None, subagent_usage_hint_text: None, + tool_namespace: None, hide_spawn_agent_metadata: None, non_code_mode_only: None, })) diff --git a/codex-rs/tools/src/tool_config.rs b/codex-rs/tools/src/tool_config.rs index 6e3ce15526..5c1681d164 100644 --- a/codex-rs/tools/src/tool_config.rs +++ b/codex-rs/tools/src/tool_config.rs @@ -121,6 +121,7 @@ pub struct ToolsConfig { pub hide_spawn_agent_metadata: bool, pub spawn_agent_usage_hint: bool, pub spawn_agent_usage_hint_text: Option, + pub multi_agent_v2_tool_namespace: Option, pub max_concurrent_threads_per_session: Option, pub wait_agent_min_timeout_ms: Option, pub wait_agent_max_timeout_ms: Option, @@ -260,6 +261,7 @@ impl ToolsConfig { hide_spawn_agent_metadata: false, spawn_agent_usage_hint: true, spawn_agent_usage_hint_text: None, + multi_agent_v2_tool_namespace: None, max_concurrent_threads_per_session: None, wait_agent_min_timeout_ms: None, wait_agent_max_timeout_ms: None, @@ -316,6 +318,14 @@ impl ToolsConfig { self } + pub fn with_multi_agent_v2_tool_namespace( + mut self, + multi_agent_v2_tool_namespace: Option, + ) -> Self { + self.multi_agent_v2_tool_namespace = multi_agent_v2_tool_namespace; + self + } + pub fn with_multi_agent_v2_non_code_mode_only( mut self, multi_agent_v2_non_code_mode_only: bool, From 0a83353ca3f7889efbf322b74476c8ebba0a2d36 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sun, 17 May 2026 08:39:41 -0700 Subject: [PATCH 3/6] test: reduce core sandbox policy test setup (#23036) ## Why `SandboxPolicy` is a legacy compatibility shape, but several core tests still used it for ordinary turn setup even when the runtime path now carries `PermissionProfile`. With the first cleanup PR merged, this follow-up trims more core test scaffolding so remaining `SandboxPolicy` matches are easier to classify as production compatibility, legacy-boundary coverage, or explicit conversion tests. ## What Changed - Updated apply-patch handler and runtime tests to pass `PermissionProfile` directly. - Changed sandboxing test helpers to build permission profiles without first creating `SandboxPolicy` values. - Converted request-permissions integration turns to pass `PermissionProfile` through the test helper, leaving legacy sandbox projection at the `Op::UserTurn` boundary. - Converted unified exec integration helpers and direct turn submissions to use `PermissionProfile` values instead of `SandboxPolicy` setup. - Removed now-unused `SandboxPolicy` imports from the touched core tests. ## Test Plan - `just fmt` - `cargo test -p codex-core --lib tools::sandboxing::tests` - `cargo test -p codex-core --lib tools::runtimes::apply_patch::tests` - `cargo test -p codex-core --lib tools::handlers::apply_patch::tests` - `cargo test -p codex-core --lib unified_exec::process_manager::tests` - `cargo test -p codex-core --test all request_permissions::` - `cargo test -p codex-core --test all unified_exec::` - `just fix -p codex-core` --- .../src/tools/handlers/apply_patch_tests.rs | 23 ++-- .../src/tools/runtimes/apply_patch_tests.rs | 4 +- codex-rs/core/src/tools/sandboxing.rs | 2 - codex-rs/core/src/tools/sandboxing_tests.rs | 15 +- .../src/unified_exec/process_manager_tests.rs | 10 +- .../core/tests/suite/request_permissions.rs | 97 ++++++------- codex-rs/core/tests/suite/unified_exec.rs | 130 +++++++++--------- 7 files changed, 127 insertions(+), 154 deletions(-) diff --git a/codex-rs/core/src/tools/handlers/apply_patch_tests.rs b/codex-rs/core/src/tools/handlers/apply_patch_tests.rs index 99854d23ea..e9a118641a 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch_tests.rs @@ -3,7 +3,6 @@ use codex_apply_patch::MaybeApplyPatchVerified; use codex_exec_server::LOCAL_FS; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::protocol::FileChange; -use codex_protocol::protocol::SandboxPolicy; use core_test_support::PathBufExt; use core_test_support::PathExt; use pretty_assertions::assert_eq; @@ -251,12 +250,11 @@ fn write_permissions_for_paths_skip_dirs_already_writable_under_workspace_root() std::fs::create_dir_all(&nested).expect("create nested dir"); let file_path = AbsolutePathBuf::try_from(nested.join("file.txt")) .expect("nested file path should be absolute"); - let sandbox_policy = FileSystemSandboxPolicy::from(&SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: false, - }); + let sandbox_policy = FileSystemSandboxPolicy::workspace_write( + &[], + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ false, + ); let permissions = write_permissions_for_paths(&[file_path], &sandbox_policy, &cwd); @@ -273,12 +271,11 @@ fn write_permissions_for_paths_keep_dirs_outside_workspace_root() { let file_path = AbsolutePathBuf::try_from(outside.join("file.txt")) .expect("outside file path should be absolute"); let cwd_abs = cwd.abs(); - let sandbox_policy = FileSystemSandboxPolicy::from(&SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }); + let sandbox_policy = FileSystemSandboxPolicy::workspace_write( + &[], + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + ); let permissions = write_permissions_for_paths(&[file_path], &sandbox_policy, &cwd_abs); let expected_outside = diff --git a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs index ff2c3d1e89..f4d8d461b2 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -7,7 +7,6 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::GranularApprovalConfig; -use codex_protocol::protocol::SandboxPolicy; use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxType; use codex_sandboxing::policy_transforms::effective_file_system_sandbox_policy; @@ -201,8 +200,7 @@ async fn file_system_sandbox_context_uses_active_attempt() { additional_permissions: Some(additional_permissions.clone()), permissions_preapproved: false, }; - let sandbox_policy = SandboxPolicy::new_read_only_policy(); - let file_system_policy = FileSystemSandboxPolicy::from(&sandbox_policy); + let file_system_policy = FileSystemSandboxPolicy::default(); let permissions = PermissionProfile::from_runtime_permissions( &file_system_policy, NetworkSandboxPolicy::Restricted, diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 656615d4d1..1ca589a5f9 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -19,8 +19,6 @@ use codex_protocol::permissions::FileSystemSandboxKind; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReviewDecision; -#[cfg(test)] -use codex_protocol::protocol::SandboxPolicy; use codex_sandboxing::SandboxCommand; use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxTransformError; diff --git a/codex-rs/core/src/tools/sandboxing_tests.rs b/codex-rs/core/src/tools/sandboxing_tests.rs index 3989862ab5..99b43699a9 100644 --- a/codex-rs/core/src/tools/sandboxing_tests.rs +++ b/codex-rs/core/src/tools/sandboxing_tests.rs @@ -5,7 +5,6 @@ use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::protocol::GranularApprovalConfig; -use codex_protocol::protocol::NetworkAccess; use pretty_assertions::assert_eq; use serde_json::json; @@ -39,13 +38,10 @@ fn bash_permission_request_payload_includes_description_when_present() { #[test] fn external_sandbox_skips_exec_approval_on_request() { - let sandbox_policy = SandboxPolicy::ExternalSandbox { - network_access: NetworkAccess::Restricted, - }; assert_eq!( default_exec_approval_requirement( AskForApproval::OnRequest, - &FileSystemSandboxPolicy::from(&sandbox_policy), + &FileSystemSandboxPolicy::external_sandbox(), ), ExecApprovalRequirement::Skip { bypass_sandbox: false, @@ -56,11 +52,10 @@ fn external_sandbox_skips_exec_approval_on_request() { #[test] fn restricted_sandbox_requires_exec_approval_on_request() { - let sandbox_policy = SandboxPolicy::new_read_only_policy(); assert_eq!( default_exec_approval_requirement( AskForApproval::OnRequest, - &FileSystemSandboxPolicy::from(&sandbox_policy) + &FileSystemSandboxPolicy::default() ), ExecApprovalRequirement::NeedsApproval { reason: None, @@ -79,9 +74,8 @@ fn default_exec_approval_requirement_rejects_sandbox_prompt_when_granular_disabl mcp_elicitations: true, }); - let sandbox_policy = SandboxPolicy::new_read_only_policy(); let requirement = - default_exec_approval_requirement(policy, &FileSystemSandboxPolicy::from(&sandbox_policy)); + default_exec_approval_requirement(policy, &FileSystemSandboxPolicy::default()); assert_eq!( requirement, @@ -101,9 +95,8 @@ fn default_exec_approval_requirement_keeps_prompt_when_granular_allows_sandbox_a mcp_elicitations: false, }); - let sandbox_policy = SandboxPolicy::new_read_only_policy(); let requirement = - default_exec_approval_requirement(policy, &FileSystemSandboxPolicy::from(&sandbox_policy)); + default_exec_approval_requirement(policy, &FileSystemSandboxPolicy::default()); assert_eq!( requirement, diff --git a/codex-rs/core/src/unified_exec/process_manager_tests.rs b/codex-rs/core/src/unified_exec/process_manager_tests.rs index c554a2a4e4..cd83ce7a9c 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -71,16 +71,10 @@ fn exec_server_params_use_env_policy_overlay_contract() { .expect("current dir") .try_into() .expect("absolute path"); - let sandbox_policy = codex_protocol::protocol::SandboxPolicy::DangerFullAccess; let file_system_sandbox_policy = - codex_protocol::permissions::FileSystemSandboxPolicy::from(&sandbox_policy); + codex_protocol::permissions::FileSystemSandboxPolicy::unrestricted(); let network_sandbox_policy = codex_protocol::permissions::NetworkSandboxPolicy::Restricted; - let permission_profile = - codex_protocol::models::PermissionProfile::from_runtime_permissions_with_enforcement( - codex_protocol::models::SandboxEnforcement::from_legacy_sandbox_policy(&sandbox_policy), - &file_system_sandbox_policy, - network_sandbox_policy, - ); + let permission_profile = codex_protocol::models::PermissionProfile::Disabled; let request = ExecRequest { command: vec!["bash".to_string(), "-lc".to_string(), "true".to_string()], cwd: cwd.clone(), diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 0463ea3e2b..cf021f29e4 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -15,7 +15,6 @@ use codex_protocol::protocol::ExecApprovalRequestEvent; use codex_protocol::protocol::GranularApprovalConfig; use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; -use codex_protocol::protocol::SandboxPolicy; use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; use codex_protocol::request_permissions::RequestPermissionsResponse; @@ -33,6 +32,7 @@ use core_test_support::skip_if_no_network; use core_test_support::skip_if_sandbox; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; +use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; use regex_lite::Regex; @@ -184,9 +184,11 @@ async fn submit_turn( test: &TestCodex, prompt: &str, approval_policy: AskForApproval, - sandbox_policy: SandboxPolicy, + permission_profile: CorePermissionProfile, ) -> Result<()> { let session_model = test.session_configured.model.clone(); + let (sandbox_policy, permission_profile) = + turn_permission_fields(permission_profile, test.cwd.path()); test.codex .submit(Op::UserTurn { environments: None, @@ -199,7 +201,7 @@ async fn submit_turn( approval_policy, approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy, - permission_profile: None, + permission_profile, model: session_model, effort: None, summary: None, @@ -285,16 +287,7 @@ async fn expect_request_permissions_event( } } -fn workspace_write_excluding_tmp() -> SandboxPolicy { - SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - } -} - -fn workspace_write_excluding_tmp_profile() -> CorePermissionProfile { +fn workspace_write_excluding_tmp() -> CorePermissionProfile { CorePermissionProfile::workspace_write_with( &[], NetworkSandboxPolicy::Restricted, @@ -330,7 +323,7 @@ async fn with_additional_permissions_requires_approval_under_on_request() -> Res let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = SandboxPolicy::new_read_only_policy(); + let permission_profile = CorePermissionProfile::read_only(); let permission_profile_for_config = CorePermissionProfile::read_only(); let mut builder = test_codex().with_config(move |config| { @@ -384,7 +377,7 @@ async fn with_additional_permissions_requires_approval_under_on_request() -> Res ) .await; - submit_turn(&test, call_id, approval_policy, sandbox_policy.clone()).await?; + submit_turn(&test, call_id, approval_policy, permission_profile.clone()).await?; let approval = expect_exec_approval(&test, command).await; assert_eq!( approval.additional_permissions, @@ -428,7 +421,7 @@ async fn request_permissions_tool_is_auto_denied_when_granular_request_permissio request_permissions: false, mcp_elicitations: true, }); - let sandbox_policy = SandboxPolicy::new_read_only_policy(); + let permission_profile = CorePermissionProfile::read_only(); let permission_profile_for_config = CorePermissionProfile::read_only(); let mut builder = test_codex().with_config(move |config| { @@ -476,7 +469,7 @@ async fn request_permissions_tool_is_auto_denied_when_granular_request_permissio &test, "request permissions under granular.request_permissions = false", approval_policy, - sandbox_policy, + permission_profile, ) .await?; @@ -514,7 +507,7 @@ async fn relative_additional_permissions_resolve_against_tool_workdir() -> Resul let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = SandboxPolicy::new_read_only_policy(); + let permission_profile = CorePermissionProfile::read_only(); let permission_profile_for_config = CorePermissionProfile::read_only(); let mut builder = test_codex().with_config(move |config| { @@ -578,7 +571,7 @@ async fn relative_additional_permissions_resolve_against_tool_workdir() -> Resul ) .await; - submit_turn(&test, call_id, approval_policy, sandbox_policy.clone()).await?; + submit_turn(&test, call_id, approval_policy, permission_profile.clone()).await?; let approval = expect_exec_approval(&test, command).await; assert_eq!( @@ -618,7 +611,7 @@ async fn read_only_with_additional_permissions_does_not_widen_to_unrequested_cwd let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = SandboxPolicy::new_read_only_policy(); + let permission_profile = CorePermissionProfile::read_only(); let permission_profile_for_config = CorePermissionProfile::read_only(); let mut builder = test_codex().with_config(move |config| { @@ -675,7 +668,7 @@ async fn read_only_with_additional_permissions_does_not_widen_to_unrequested_cwd ) .await; - submit_turn(&test, call_id, approval_policy, sandbox_policy.clone()).await?; + submit_turn(&test, call_id, approval_policy, permission_profile.clone()).await?; let approval = expect_exec_approval(&test, &command).await; assert_eq!( @@ -721,7 +714,7 @@ async fn read_only_with_additional_permissions_does_not_widen_to_unrequested_tmp let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = SandboxPolicy::new_read_only_policy(); + let permission_profile = CorePermissionProfile::read_only(); let permission_profile_for_config = CorePermissionProfile::read_only(); let mut builder = test_codex().with_config(move |config| { @@ -779,7 +772,7 @@ async fn read_only_with_additional_permissions_does_not_widen_to_unrequested_tmp ) .await; - submit_turn(&test, call_id, approval_policy, sandbox_policy.clone()).await?; + submit_turn(&test, call_id, approval_policy, permission_profile.clone()).await?; let approval = expect_exec_approval(&test, &command).await; assert_eq!( @@ -823,8 +816,8 @@ async fn workspace_write_with_additional_permissions_can_write_outside_cwd() -> let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = workspace_write_excluding_tmp(); - let permission_profile_for_config = workspace_write_excluding_tmp_profile(); + let permission_profile = workspace_write_excluding_tmp(); + let permission_profile_for_config = workspace_write_excluding_tmp(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); @@ -890,7 +883,7 @@ async fn workspace_write_with_additional_permissions_can_write_outside_cwd() -> ) .await; - submit_turn(&test, call_id, approval_policy, sandbox_policy.clone()).await?; + submit_turn(&test, call_id, approval_policy, permission_profile.clone()).await?; let approval = expect_exec_approval(&test, &command).await; assert_eq!( @@ -930,8 +923,8 @@ async fn with_additional_permissions_denied_approval_blocks_execution() -> Resul skip_if_no_network!(Ok(())); let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = workspace_write_excluding_tmp(); - let permission_profile_for_config = workspace_write_excluding_tmp_profile(); + let permission_profile = workspace_write_excluding_tmp(); + let permission_profile_for_config = workspace_write_excluding_tmp(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); @@ -995,7 +988,7 @@ async fn with_additional_permissions_denied_approval_blocks_execution() -> Resul ) .await; - submit_turn(&test, call_id, approval_policy, sandbox_policy.clone()).await?; + submit_turn(&test, call_id, approval_policy, permission_profile.clone()).await?; let approval = expect_exec_approval(&test, &command).await; assert_eq!( @@ -1038,8 +1031,8 @@ async fn request_permissions_grants_apply_to_later_exec_command_calls() -> Resul let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = workspace_write_excluding_tmp(); - let permission_profile_for_config = workspace_write_excluding_tmp_profile(); + let permission_profile = workspace_write_excluding_tmp(); + let permission_profile_for_config = workspace_write_excluding_tmp(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); @@ -1110,7 +1103,7 @@ async fn request_permissions_grants_apply_to_later_exec_command_calls() -> Resul &test, "write outside the workspace", approval_policy, - sandbox_policy, + permission_profile, ) .await?; @@ -1165,8 +1158,8 @@ async fn request_permissions_preapprove_explicit_exec_permissions_outside_on_req let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = workspace_write_excluding_tmp(); - let permission_profile_for_config = workspace_write_excluding_tmp_profile(); + let permission_profile = workspace_write_excluding_tmp(); + let permission_profile_for_config = workspace_write_excluding_tmp(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); @@ -1228,7 +1221,7 @@ async fn request_permissions_preapprove_explicit_exec_permissions_outside_on_req &test, "write outside the workspace", approval_policy, - sandbox_policy, + permission_profile, ) .await?; @@ -1286,8 +1279,8 @@ async fn request_permissions_grants_apply_to_later_shell_command_calls() -> Resu let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = workspace_write_excluding_tmp(); - let permission_profile_for_config = workspace_write_excluding_tmp_profile(); + let permission_profile = workspace_write_excluding_tmp(); + let permission_profile_for_config = workspace_write_excluding_tmp(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); @@ -1345,7 +1338,7 @@ async fn request_permissions_grants_apply_to_later_shell_command_calls() -> Resu &test, "write outside the workspace", approval_policy, - sandbox_policy, + permission_profile, ) .await?; @@ -1401,8 +1394,8 @@ async fn request_permissions_grants_apply_to_later_shell_command_calls_without_i let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = workspace_write_excluding_tmp(); - let permission_profile_for_config = workspace_write_excluding_tmp_profile(); + let permission_profile = workspace_write_excluding_tmp(); + let permission_profile_for_config = workspace_write_excluding_tmp(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); @@ -1458,7 +1451,7 @@ async fn request_permissions_grants_apply_to_later_shell_command_calls_without_i &test, "write outside the workspace without inline permission feature", approval_policy, - sandbox_policy, + permission_profile, ) .await?; @@ -1516,8 +1509,8 @@ async fn partial_request_permissions_grants_do_not_preapprove_new_permissions() let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = workspace_write_excluding_tmp(); - let permission_profile_for_config = workspace_write_excluding_tmp_profile(); + let permission_profile = workspace_write_excluding_tmp(); + let permission_profile_for_config = workspace_write_excluding_tmp(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); @@ -1611,7 +1604,7 @@ async fn partial_request_permissions_grants_do_not_preapprove_new_permissions() &test, "write outside the workspace", approval_policy, - sandbox_policy, + permission_profile, ) .await?; @@ -1683,8 +1676,8 @@ async fn request_permissions_grants_do_not_carry_across_turns() -> Result<()> { let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = workspace_write_excluding_tmp(); - let permission_profile_for_config = workspace_write_excluding_tmp_profile(); + let permission_profile = workspace_write_excluding_tmp(); + let permission_profile_for_config = workspace_write_excluding_tmp(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); @@ -1733,7 +1726,7 @@ async fn request_permissions_grants_do_not_carry_across_turns() -> Result<()> { &test, "request permissions for later use", approval_policy, - sandbox_policy.clone(), + permission_profile.clone(), ) .await?; @@ -1778,7 +1771,7 @@ async fn request_permissions_grants_do_not_carry_across_turns() -> Result<()> { &test, "try to reuse permissions in a later turn", approval_policy, - sandbox_policy, + permission_profile, ) .await?; wait_for_completion(&test).await; @@ -1799,8 +1792,8 @@ async fn request_permissions_session_grants_carry_across_turns() -> Result<()> { let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = workspace_write_excluding_tmp(); - let permission_profile_for_config = workspace_write_excluding_tmp_profile(); + let permission_profile = workspace_write_excluding_tmp(); + let permission_profile_for_config = workspace_write_excluding_tmp(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); @@ -1854,7 +1847,7 @@ async fn request_permissions_session_grants_carry_across_turns() -> Result<()> { &test, "request session permissions for later use", approval_policy, - sandbox_policy.clone(), + permission_profile.clone(), ) .await?; @@ -1896,7 +1889,7 @@ async fn request_permissions_session_grants_carry_across_turns() -> Result<()> { &test, "reuse session permissions in a later turn", approval_policy, - sandbox_policy, + permission_profile, ) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 92b8aa9047..6b962116af 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -15,7 +15,6 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecCommandSource; use codex_protocol::protocol::ExecCommandStatus; use codex_protocol::protocol::Op; -use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; use core_test_support::assert_regex_match; use core_test_support::managed_network_requirements_loader; @@ -35,6 +34,7 @@ use core_test_support::skip_if_windows; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::TestCodexHarness; use core_test_support::test_codex::test_codex; +use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; use core_test_support::wait_for_event_with_timeout; @@ -185,9 +185,11 @@ async fn wait_for_raw_unified_exec_output( async fn submit_unified_exec_turn( test: &TestCodex, prompt: &str, - sandbox_policy: SandboxPolicy, + permission_profile: PermissionProfile, ) -> Result<()> { let session_model = test.session_configured.model.clone(); + let (sandbox_policy, permission_profile) = + turn_permission_fields(permission_profile, test.config.cwd.as_path()); test.codex .submit(Op::UserTurn { @@ -201,7 +203,7 @@ async fn submit_unified_exec_turn( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy, - permission_profile: None, + permission_profile, model: session_model, effort: None, summary: None, @@ -272,6 +274,8 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { let codex = test.codex.clone(); let cwd = test.cwd_path().to_path_buf(); let session_model = test.session_configured.model.clone(); + let (sandbox_policy, permission_profile) = + turn_permission_fields(PermissionProfile::Disabled, &cwd); codex .submit(Op::UserTurn { @@ -284,8 +288,8 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { cwd, approval_policy: AskForApproval::Never, approvals_reviewer: None, - sandbox_policy: SandboxPolicy::DangerFullAccess, - permission_profile: None, + sandbox_policy, + permission_profile, model: session_model, effort: None, summary: None, @@ -403,7 +407,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { ]; mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn(&test, "emit begin event", SandboxPolicy::DangerFullAccess).await?; + submit_unified_exec_turn(&test, "emit begin event", PermissionProfile::Disabled).await?; let begin_event = wait_for_event_match(&test.codex, |msg| match msg { EventMsg::ExecCommandBegin(event) if event.call_id == call_id => Some(event.clone()), @@ -467,7 +471,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { submit_unified_exec_turn( &test, "run relative workdir test", - SandboxPolicy::DangerFullAccess, + PermissionProfile::Disabled, ) .await?; @@ -532,7 +536,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { ]; let request_log = mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn(&test, "run workdir test", SandboxPolicy::DangerFullAccess).await?; + submit_unified_exec_turn(&test, "run workdir test", PermissionProfile::Disabled).await?; let begin_event = wait_for_event_match(&test.codex, |msg| match msg { EventMsg::ExecCommandBegin(event) if event.call_id == call_id => Some(event.clone()), @@ -609,7 +613,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { ]; mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn(&test, "emit end event", SandboxPolicy::DangerFullAccess).await?; + submit_unified_exec_turn(&test, "emit end event", PermissionProfile::Disabled).await?; let end_event = wait_for_event_match(&test.codex, |msg| match msg { EventMsg::ExecCommandEnd(ev) if ev.call_id == call_id => Some(ev.clone()), @@ -667,7 +671,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { ]; mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn(&test, "emit delta", SandboxPolicy::DangerFullAccess).await?; + submit_unified_exec_turn(&test, "emit delta", PermissionProfile::Disabled).await?; let event = wait_for_event_match(&test.codex, |msg| match msg { EventMsg::ExecCommandEnd(ev) if ev.call_id == call_id => Some(ev.clone()), @@ -729,7 +733,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { submit_unified_exec_turn( &test, "exercise full unified exec lifecycle", - SandboxPolicy::DangerFullAccess, + PermissionProfile::Disabled, ) .await?; @@ -872,7 +876,7 @@ async fn unified_exec_short_lived_network_denial_emits_failed_end_event() -> Res #[allow(clippy::expect_used)] async fn unified_exec_network_denial_test( server: &wiremock::MockServer, -) -> Result<(TestCodex, SandboxPolicy)> { +) -> Result<(TestCodex, PermissionProfile)> { use codex_config::Constrained; use std::sync::Arc; use tempfile::TempDir; @@ -897,10 +901,7 @@ allow_local_binding = true /*exclude_tmpdir_env_var*/ false, /*exclude_slash_tmp*/ false, ); - let sandbox_policy = permission_profile_for_config - .clone() - .to_legacy_sandbox_policy(home.path()) - .expect("workspace-write profile should project to legacy policy"); + let permission_profile = permission_profile_for_config.clone(); let mut builder = test_codex() .with_home(home) .with_cloud_requirements(managed_network_requirements_loader()) @@ -922,7 +923,7 @@ allow_local_binding = true "expected managed network proxy config to be present" ); - Ok((test, sandbox_policy)) + Ok((test, permission_profile)) } async fn mount_unified_exec_network_denial_responses( @@ -1040,7 +1041,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> ]; mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn(&test, "stdin delta", SandboxPolicy::DangerFullAccess).await?; + submit_unified_exec_turn(&test, "stdin delta", PermissionProfile::Disabled).await?; let mut terminal_interaction = None; @@ -1160,7 +1161,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( submit_unified_exec_turn( &test, "delayed terminal interaction output", - SandboxPolicy::DangerFullAccess, + PermissionProfile::Disabled, ) .await?; @@ -1305,7 +1306,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { submit_unified_exec_turn( &test, "check poll event behavior", - SandboxPolicy::DangerFullAccess, + PermissionProfile::Disabled, ) .await?; @@ -1395,7 +1396,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { ]; let request_log = mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn(&test, "run metadata test", SandboxPolicy::DangerFullAccess).await?; + submit_unified_exec_turn(&test, "run metadata test", PermissionProfile::Disabled).await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -1494,7 +1495,7 @@ async fn exec_command_clamps_model_requested_max_output_tokens_to_policy() -> Re submit_unified_exec_turn( &test, "run clamped max output test", - SandboxPolicy::DangerFullAccess, + PermissionProfile::Disabled, ) .await?; @@ -1577,7 +1578,7 @@ async fn write_stdin_clamps_model_requested_max_output_tokens_to_policy() -> Res submit_unified_exec_turn( &test, "run clamped write_stdin output test", - SandboxPolicy::DangerFullAccess, + PermissionProfile::Disabled, ) .await?; @@ -1642,7 +1643,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { submit_unified_exec_turn( &test, "check default pipe mode", - SandboxPolicy::DangerFullAccess, + PermissionProfile::Disabled, ) .await?; @@ -1709,7 +1710,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { ]; let request_log = mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn(&test, "check tty enabled", SandboxPolicy::DangerFullAccess).await?; + submit_unified_exec_turn(&test, "check tty enabled", PermissionProfile::Disabled).await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -1776,7 +1777,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { submit_unified_exec_turn( &test, "watch early exit timing", - SandboxPolicy::DangerFullAccess, + PermissionProfile::Disabled, ) .await?; @@ -1895,7 +1896,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { submit_unified_exec_turn( &test, "test write_stdin exit behavior", - SandboxPolicy::DangerFullAccess, + PermissionProfile::Disabled, ) .await?; @@ -2048,7 +2049,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() ]; mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn(&test, "end on exit", SandboxPolicy::DangerFullAccess).await?; + submit_unified_exec_turn(&test, "end on exit", PermissionProfile::Disabled).await?; // We expect the ExecCommandEnd event to match the initial exec_command call_id. let end_event = wait_for_event_match(&test.codex, |msg| match msg { @@ -2114,6 +2115,9 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> mount_sse_sequence(&server, responses).await; let session_model = session_configured.model.clone(); + let turn_cwd = cwd.path().to_path_buf(); + let (sandbox_policy, permission_profile) = + turn_permission_fields(PermissionProfile::Disabled, &turn_cwd); codex .submit(Op::UserTurn { @@ -2123,11 +2127,11 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), + cwd: turn_cwd, approval_policy: AskForApproval::Never, approvals_reviewer: None, - sandbox_policy: SandboxPolicy::DangerFullAccess, - permission_profile: None, + sandbox_policy, + permission_profile, model: session_model, effort: None, summary: None, @@ -2209,6 +2213,9 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { mount_sse_sequence(&server, responses).await; let session_model = session_configured.model.clone(); + let turn_cwd = cwd.path().to_path_buf(); + let (sandbox_policy, permission_profile) = + turn_permission_fields(PermissionProfile::Disabled, &turn_cwd); codex .submit(Op::UserTurn { @@ -2218,11 +2225,11 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), + cwd: turn_cwd, approval_policy: AskForApproval::Never, approvals_reviewer: None, - sandbox_policy: SandboxPolicy::DangerFullAccess, - permission_profile: None, + sandbox_policy, + permission_profile, model: session_model, effort: None, summary: None, @@ -2314,7 +2321,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { ]; let request_log = mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn(&test, "run unified exec", SandboxPolicy::DangerFullAccess).await?; + submit_unified_exec_turn(&test, "run unified exec", PermissionProfile::Disabled).await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -2432,12 +2439,7 @@ PY ]; let request_log = mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn( - &test, - "exercise lag handling", - SandboxPolicy::DangerFullAccess, - ) - .await?; + submit_unified_exec_turn(&test, "exercise lag handling", PermissionProfile::Disabled).await?; // This is a worst case scenario for the truncate logic, and CI can spend a // while draining the lagged tail before the follow-up tool call completes. wait_for_event_with_timeout( @@ -2532,7 +2534,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { ]; let request_log = mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn(&test, "check timeout", SandboxPolicy::DangerFullAccess).await?; + submit_unified_exec_turn(&test, "check timeout", PermissionProfile::Disabled).await?; loop { let event = test.codex.next_event().await.expect("event"); @@ -2608,12 +2610,7 @@ PY ]; let request_log = mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn( - &test, - "summarize large output", - SandboxPolicy::DangerFullAccess, - ) - .await?; + submit_unified_exec_turn(&test, "summarize large output", PermissionProfile::Disabled).await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -2683,6 +2680,9 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { let request_log = mount_sse_sequence(&server, responses).await; let session_model = session_configured.model.clone(); + let turn_cwd = cwd.path().to_path_buf(); + let (sandbox_policy, permission_profile) = + turn_permission_fields(PermissionProfile::read_only(), &turn_cwd); codex .submit(Op::UserTurn { @@ -2692,12 +2692,12 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), + cwd: turn_cwd, approval_policy: AskForApproval::Never, approvals_reviewer: None, // Important! - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + sandbox_policy, + permission_profile, model: session_model, effort: None, summary: None, @@ -2799,7 +2799,9 @@ async fn unified_exec_enforces_glob_deny_read_policy() -> Result<()> { let request_log = mount_sse_sequence(&server, responses).await; let session_model = session_configured.model.clone(); - let read_only_policy = SandboxPolicy::new_read_only_policy(); + let turn_cwd = cwd.path().to_path_buf(); + let (sandbox_policy, permission_profile) = + turn_permission_fields(PermissionProfile::read_only(), &turn_cwd); codex .submit(Op::UserTurn { environments: None, @@ -2808,11 +2810,11 @@ async fn unified_exec_enforces_glob_deny_read_policy() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), + cwd: turn_cwd, approval_policy: AskForApproval::Never, approvals_reviewer: None, - sandbox_policy: read_only_policy, - permission_profile: None, + sandbox_policy, + permission_profile, model: session_model, effort: None, summary: None, @@ -2929,6 +2931,9 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { let request_log = mount_sse_sequence(&server, responses).await; let session_model = session_configured.model.clone(); + let turn_cwd = cwd.path().to_path_buf(); + let (sandbox_policy, permission_profile) = + turn_permission_fields(PermissionProfile::read_only(), &turn_cwd); codex .submit(Op::UserTurn { @@ -2938,11 +2943,11 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), + cwd: turn_cwd, approval_policy: AskForApproval::Never, approvals_reviewer: None, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + sandbox_policy, + permission_profile, model: session_model, effort: None, summary: None, @@ -3025,12 +3030,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { ]; let request_log = mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn( - &test, - "summarize large output", - SandboxPolicy::DangerFullAccess, - ) - .await?; + submit_unified_exec_turn(&test, "summarize large output", PermissionProfile::Disabled).await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -3148,7 +3148,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { let response_mock = mount_sse_sequence(&server, vec![first_response, completion_response]).await; - submit_unified_exec_turn(&test, "fill session cache", SandboxPolicy::DangerFullAccess).await?; + submit_unified_exec_turn(&test, "fill session cache", PermissionProfile::Disabled).await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) From e7bffc5a20e92cbc64d6c16a1b257d0b2e4cd5df Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Sun, 17 May 2026 09:05:44 -0700 Subject: [PATCH 4/6] [codex] Accept string input for Python turns (#23162) ## Summary - Allow thread.turn and turn.steer, including async variants, to accept RunInput so plain strings work alongside typed input objects. - Export RunInput and update the SDK artifact generator so regenerated turn methods keep the same signature and normalization. - Update docs, examples, notebook cells, and tests to use string shorthand for text-only turns while keeping typed inputs for multimodal input. ## Validation - uv run --extra dev ruff format . - uv run --extra dev ruff check --output-format=github . - python3 -m py_compile sdk/python/src/openai_codex/__init__.py sdk/python/src/openai_codex/api.py sdk/python/src/openai_codex/_inputs.py sdk/python/scripts/update_sdk_artifacts.py sdk/python/tests/test_public_api_signatures.py sdk/python/tests/test_app_server_streaming.py sdk/python/tests/test_app_server_turn_controls.py sdk/python/tests/test_real_app_server_integration.py - python3 -c "import json; json.load(open('sdk/python/notebooks/sdk_walkthrough.ipynb'))" - sdk/python/.venv/bin/python -c "import inspect, openai_codex; from openai_codex import Thread, AsyncThread, TurnHandle, AsyncTurnHandle, RunInput; funcs=[Thread.run, Thread.turn, AsyncThread.run, AsyncThread.turn, TurnHandle.steer, AsyncTurnHandle.steer]; assert all(inspect.signature(fn).parameters['input'].annotation == 'RunInput' for fn in funcs); assert RunInput is openai_codex.RunInput" --- sdk/python/README.md | 2 + sdk/python/docs/api-reference.md | 19 +++-- sdk/python/docs/getting-started.md | 1 + sdk/python/examples/02_turn_run/async.py | 4 +- sdk/python/examples/02_turn_run/sync.py | 4 +- .../examples/03_turn_stream_events/async.py | 4 +- .../examples/03_turn_stream_events/sync.py | 4 +- .../examples/05_existing_thread/async.py | 6 +- .../examples/05_existing_thread/sync.py | 6 +- .../06_thread_lifecycle_and_controls/async.py | 14 ++-- .../06_thread_lifecycle_and_controls/sync.py | 10 +-- sdk/python/examples/09_async_parity/sync.py | 4 +- .../10_error_handling_and_retry/async.py | 3 +- .../10_error_handling_and_retry/sync.py | 3 +- sdk/python/examples/11_cli_mini_app/async.py | 3 +- sdk/python/examples/11_cli_mini_app/sync.py | 3 +- .../12_turn_params_kitchen_sink/async.py | 3 +- .../12_turn_params_kitchen_sink/sync.py | 3 +- .../13_model_select_and_turn_params/async.py | 5 +- .../13_model_select_and_turn_params/sync.py | 5 +- sdk/python/examples/14_turn_controls/async.py | 10 +-- sdk/python/examples/14_turn_controls/sync.py | 12 +-- sdk/python/examples/README.md | 3 + sdk/python/notebooks/sdk_walkthrough.ipynb | 73 +++++++++++-------- sdk/python/scripts/update_sdk_artifacts.py | 8 +- sdk/python/src/openai_codex/__init__.py | 2 + sdk/python/src/openai_codex/api.py | 26 ++++--- sdk/python/tests/test_app_server_streaming.py | 22 ++++-- .../tests/test_app_server_turn_controls.py | 8 +- .../tests/test_public_api_signatures.py | 17 +++++ .../tests/test_real_app_server_integration.py | 18 ++--- 31 files changed, 171 insertions(+), 134 deletions(-) diff --git a/sdk/python/README.md b/sdk/python/README.md index 9107d15ba9..5b8e6b8178 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -102,6 +102,8 @@ target wheel. The SDK package version and runtime package version must match. - `Codex()` is eager and performs startup + `initialize` in the constructor. - Use context managers (`with Codex() as codex:`) to ensure shutdown. +- Plain strings are accepted anywhere a turn input is accepted; they are + shorthand for `TextInput(...)`. - Prefer `thread.run("...")` for the common case. Use `thread.turn(...)` when you need streaming, steering, or interrupt control. - For transient overload, use `retry_on_overload` from the package root. diff --git a/sdk/python/docs/api-reference.md b/sdk/python/docs/api-reference.md index 4bee871f8c..db78ff5f1d 100644 --- a/sdk/python/docs/api-reference.md +++ b/sdk/python/docs/api-reference.md @@ -3,7 +3,7 @@ Public surface of `openai_codex` for app-server v2. This SDK surface is experimental. Turn streams are routed by turn ID so one client can consume multiple active turns concurrently. -Thread and turn starts expose `approval_mode`. `ApprovalMode.auto_review` is the default; use `ApprovalMode.deny_all` to deny escalated permissions. +Thread starts default to `ApprovalMode.auto_review`; turn starts accept an optional `approval_mode` override. ## Package Entry @@ -23,6 +23,7 @@ from openai_codex import ( TurnResult, Input, InputItem, + RunInput, TextInput, ImageInput, LocalImageInput, @@ -147,16 +148,16 @@ attempt. API-key login completes synchronously and does not return a handle. ### Thread -- `run(input: str | Input, *, approval_mode=ApprovalMode.auto_review, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> TurnResult` -- `turn(input: Input, *, approval_mode=ApprovalMode.auto_review, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, summary=None) -> TurnHandle` +- `run(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=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_policy=None, service_tier=None, summary=None) -> TurnHandle` - `read(*, include_turns: bool = False) -> ThreadReadResponse` - `set_name(name: str) -> ThreadSetNameResponse` - `compact() -> ThreadCompactStartResponse` ### AsyncThread -- `run(input: str | Input, *, approval_mode=ApprovalMode.auto_review, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> Awaitable[TurnResult]` -- `turn(input: Input, *, approval_mode=ApprovalMode.auto_review, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, summary=None) -> Awaitable[AsyncTurnHandle]` +- `run(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=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_policy=None, service_tier=None, summary=None) -> Awaitable[AsyncTurnHandle]` - `read(*, include_turns: bool = False) -> Awaitable[ThreadReadResponse]` - `set_name(name: str) -> Awaitable[ThreadSetNameResponse]` - `compact() -> Awaitable[ThreadCompactStartResponse]` @@ -185,7 +186,7 @@ Use `turn(...)` when you need low-level turn control (`stream()`, `steer()`, ### TurnHandle -- `steer(input: Input) -> TurnSteerResponse` +- `steer(input: str | Input) -> TurnSteerResponse` - `interrupt() -> TurnInterruptResponse` - `stream() -> Iterator[Notification]` - `run() -> TurnResult` @@ -197,7 +198,7 @@ Behavior notes: ### AsyncTurnHandle -- `steer(input: Input) -> Awaitable[TurnSteerResponse]` +- `steer(input: str | Input) -> Awaitable[TurnSteerResponse]` - `interrupt() -> Awaitable[TurnInterruptResponse]` - `stream() -> AsyncIterator[Notification]` - `run() -> Awaitable[TurnResult]` @@ -218,8 +219,12 @@ Behavior notes: InputItem = TextInput | ImageInput | LocalImageInput | SkillInput | MentionInput Input = list[InputItem] | InputItem +RunInput = Input | str ``` +Use a plain `str` as shorthand for `TextInput(...)` anywhere a turn input is accepted: +`thread.run("...")`, `thread.turn("...")`, and `turn.steer("...")`. + ## Public Types The SDK wrappers return and accept public app-server models wherever possible: diff --git a/sdk/python/docs/getting-started.md b/sdk/python/docs/getting-started.md index 1df0026c68..b21db2a207 100644 --- a/sdk/python/docs/getting-started.md +++ b/sdk/python/docs/getting-started.md @@ -72,6 +72,7 @@ What happened: - `thread_start(...)` created a thread. - `thread.run("...")` started a turn, consumed events until completion, and returned `TurnResult` with turn metadata, final assistant response, collected items, and usage. - `result.final_response` is `None` when no final-answer or phase-less assistant message item completes for the turn. +- plain strings are accepted anywhere a turn input is accepted; typed inputs are still available for multimodal and structured cases - use `thread.turn(...)` when you need a `TurnHandle` for streaming, steering, or interrupting before collecting `TurnResult` - one client can consume multiple active turns concurrently; turn streams are routed by turn ID diff --git a/sdk/python/examples/02_turn_run/async.py b/sdk/python/examples/02_turn_run/async.py index debfad8d6e..c4071cde47 100644 --- a/sdk/python/examples/02_turn_run/async.py +++ b/sdk/python/examples/02_turn_run/async.py @@ -11,7 +11,7 @@ ensure_local_sdk_src() import asyncio -from openai_codex import AsyncCodex, TextInput +from openai_codex import AsyncCodex async def main() -> None: @@ -19,7 +19,7 @@ async def main() -> None: thread = await codex.thread_start( model="gpt-5.4", config={"model_reasoning_effort": "high"} ) - turn = await thread.turn(TextInput("Give 3 bullets about SIMD.")) + turn = await thread.turn("Give 3 bullets about SIMD.") result = await turn.run() print("thread_id:", thread.id) diff --git a/sdk/python/examples/02_turn_run/sync.py b/sdk/python/examples/02_turn_run/sync.py index c42bffa1f2..af22a220ba 100644 --- a/sdk/python/examples/02_turn_run/sync.py +++ b/sdk/python/examples/02_turn_run/sync.py @@ -9,11 +9,11 @@ from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() -from openai_codex import Codex, TextInput +from openai_codex import Codex with Codex(config=runtime_config()) as codex: thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) - result = thread.turn(TextInput("Give 3 bullets about SIMD.")).run() + result = thread.turn("Give 3 bullets about SIMD.").run() print("thread_id:", thread.id) print("turn_id:", result.id) diff --git a/sdk/python/examples/03_turn_stream_events/async.py b/sdk/python/examples/03_turn_stream_events/async.py index f0d3f1427d..e145b2222b 100644 --- a/sdk/python/examples/03_turn_stream_events/async.py +++ b/sdk/python/examples/03_turn_stream_events/async.py @@ -11,7 +11,7 @@ ensure_local_sdk_src() import asyncio -from openai_codex import AsyncCodex, TextInput +from openai_codex import AsyncCodex async def main() -> None: @@ -19,7 +19,7 @@ async def main() -> None: thread = await codex.thread_start( model="gpt-5.4", config={"model_reasoning_effort": "high"} ) - turn = await thread.turn(TextInput("Explain SIMD in 3 short bullets.")) + turn = await thread.turn("Explain SIMD in 3 short bullets.") event_count = 0 saw_started = False diff --git a/sdk/python/examples/03_turn_stream_events/sync.py b/sdk/python/examples/03_turn_stream_events/sync.py index 5a76bcb70f..f5a734dc38 100644 --- a/sdk/python/examples/03_turn_stream_events/sync.py +++ b/sdk/python/examples/03_turn_stream_events/sync.py @@ -9,11 +9,11 @@ from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() -from openai_codex import Codex, TextInput +from openai_codex import Codex with Codex(config=runtime_config()) as codex: thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) - turn = thread.turn(TextInput("Explain SIMD in 3 short bullets.")) + turn = thread.turn("Explain SIMD in 3 short bullets.") event_count = 0 saw_started = False diff --git a/sdk/python/examples/05_existing_thread/async.py b/sdk/python/examples/05_existing_thread/async.py index df3946ac87..e1f4db7105 100644 --- a/sdk/python/examples/05_existing_thread/async.py +++ b/sdk/python/examples/05_existing_thread/async.py @@ -11,7 +11,7 @@ ensure_local_sdk_src() import asyncio -from openai_codex import AsyncCodex, TextInput +from openai_codex import AsyncCodex async def main() -> None: @@ -20,12 +20,12 @@ async def main() -> None: model="gpt-5.4", config={"model_reasoning_effort": "high"} ) - first_turn = await original.turn(TextInput("Tell me one fact about Saturn.")) + first_turn = await original.turn("Tell me one fact about Saturn.") _ = await first_turn.run() print("Created thread:", original.id) resumed = await codex.thread_resume(original.id) - second_turn = await resumed.turn(TextInput("Continue with one more fact.")) + second_turn = await resumed.turn("Continue with one more fact.") second = await second_turn.run() print(second.final_response) diff --git a/sdk/python/examples/05_existing_thread/sync.py b/sdk/python/examples/05_existing_thread/sync.py index a0f0d37e27..7110ebac7f 100644 --- a/sdk/python/examples/05_existing_thread/sync.py +++ b/sdk/python/examples/05_existing_thread/sync.py @@ -9,15 +9,15 @@ from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() -from openai_codex import Codex, TextInput +from openai_codex import Codex with Codex(config=runtime_config()) as codex: # Create an initial thread and turn so we have a real thread to resume. original = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) - first = original.turn(TextInput("Tell me one fact about Saturn.")).run() + first = original.turn("Tell me one fact about Saturn.").run() print("Created thread:", original.id) # Resume the existing thread by ID. resumed = codex.thread_resume(original.id) - second = resumed.turn(TextInput("Continue with one more fact.")).run() + second = resumed.turn("Continue with one more fact.").run() print(second.final_response) diff --git a/sdk/python/examples/06_thread_lifecycle_and_controls/async.py b/sdk/python/examples/06_thread_lifecycle_and_controls/async.py index 24afbde6c2..7a786a1d79 100644 --- a/sdk/python/examples/06_thread_lifecycle_and_controls/async.py +++ b/sdk/python/examples/06_thread_lifecycle_and_controls/async.py @@ -11,7 +11,7 @@ ensure_local_sdk_src() import asyncio -from openai_codex import AsyncCodex, TextInput +from openai_codex import AsyncCodex async def main() -> None: @@ -19,10 +19,8 @@ async def main() -> None: thread = await codex.thread_start( model="gpt-5.4", config={"model_reasoning_effort": "high"} ) - first = await ( - await thread.turn(TextInput("One sentence about structured planning.")) - ).run() - second = await (await thread.turn(TextInput("Now restate it for a junior engineer."))).run() + first = await (await thread.turn("One sentence about structured planning.")).run() + second = await (await thread.turn("Now restate it for a junior engineer.")).run() reopened = await codex.thread_resume(thread.id) listing_active = await codex.thread_list(limit=20, archived=False) @@ -38,13 +36,11 @@ async def main() -> None: model="gpt-5.4", config={"model_reasoning_effort": "high"}, ) - resumed_result = await ( - await resumed.turn(TextInput("Continue in one short sentence.")) - ).run() + resumed_result = await (await resumed.turn("Continue in one short sentence.")).run() forked = await codex.thread_fork(unarchived.id, model="gpt-5.4") forked_result = await ( - await forked.turn(TextInput("Take a different angle in one short sentence.")) + await forked.turn("Take a different angle in one short sentence.") ).run() compact_result = await unarchived.compact() diff --git a/sdk/python/examples/06_thread_lifecycle_and_controls/sync.py b/sdk/python/examples/06_thread_lifecycle_and_controls/sync.py index 0958002dd9..744b56ebb7 100644 --- a/sdk/python/examples/06_thread_lifecycle_and_controls/sync.py +++ b/sdk/python/examples/06_thread_lifecycle_and_controls/sync.py @@ -9,12 +9,12 @@ from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() -from openai_codex import Codex, TextInput +from openai_codex import Codex with Codex(config=runtime_config()) as codex: thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) - first = thread.turn(TextInput("One sentence about structured planning.")).run() - second = thread.turn(TextInput("Now restate it for a junior engineer.")).run() + first = thread.turn("One sentence about structured planning.").run() + second = thread.turn("Now restate it for a junior engineer.").run() reopened = codex.thread_resume(thread.id) listing_active = codex.thread_list(limit=20, archived=False) @@ -30,10 +30,10 @@ with Codex(config=runtime_config()) as codex: model="gpt-5.4", config={"model_reasoning_effort": "high"}, ) - resumed_result = resumed.turn(TextInput("Continue in one short sentence.")).run() + resumed_result = resumed.turn("Continue in one short sentence.").run() forked = codex.thread_fork(unarchived.id, model="gpt-5.4") - forked_result = forked.turn(TextInput("Take a different angle in one short sentence.")).run() + forked_result = forked.turn("Take a different angle in one short sentence.").run() compact_result = unarchived.compact() diff --git a/sdk/python/examples/09_async_parity/sync.py b/sdk/python/examples/09_async_parity/sync.py index 4b5d567159..a7ac8abbd7 100644 --- a/sdk/python/examples/09_async_parity/sync.py +++ b/sdk/python/examples/09_async_parity/sync.py @@ -9,13 +9,13 @@ from _bootstrap import ensure_local_sdk_src, runtime_config, server_label ensure_local_sdk_src() -from openai_codex import Codex, TextInput +from openai_codex import Codex with Codex(config=runtime_config()) as codex: print("Server:", server_label(codex.metadata)) thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) - turn = thread.turn(TextInput("Say hello in one sentence.")) + turn = thread.turn("Say hello in one sentence.") result = turn.run() print("Thread:", thread.id) diff --git a/sdk/python/examples/10_error_handling_and_retry/async.py b/sdk/python/examples/10_error_handling_and_retry/async.py index f1711bffc9..71f954544f 100644 --- a/sdk/python/examples/10_error_handling_and_retry/async.py +++ b/sdk/python/examples/10_error_handling_and_retry/async.py @@ -18,7 +18,6 @@ from openai_codex import ( AsyncCodex, JsonRpcError, ServerBusyError, - TextInput, is_retryable_error, ) from openai_codex.types import TurnStatus @@ -82,7 +81,7 @@ async def main() -> None: def _run_turn(thread, prompt: str): async def _inner(): - turn = await thread.turn(TextInput(prompt)) + turn = await thread.turn(prompt) return await turn.run() return _inner diff --git a/sdk/python/examples/10_error_handling_and_retry/sync.py b/sdk/python/examples/10_error_handling_and_retry/sync.py index c134063c80..9c0f9f30e5 100644 --- a/sdk/python/examples/10_error_handling_and_retry/sync.py +++ b/sdk/python/examples/10_error_handling_and_retry/sync.py @@ -13,7 +13,6 @@ from openai_codex import ( Codex, JsonRpcError, ServerBusyError, - TextInput, retry_on_overload, ) from openai_codex.types import TurnStatus @@ -23,7 +22,7 @@ with Codex(config=runtime_config()) as codex: try: result = retry_on_overload( - lambda: thread.turn(TextInput("Summarize retry best practices in 3 bullets.")).run(), + lambda: thread.turn("Summarize retry best practices in 3 bullets.").run(), max_attempts=3, initial_delay_s=0.25, max_delay_s=2.0, diff --git a/sdk/python/examples/11_cli_mini_app/async.py b/sdk/python/examples/11_cli_mini_app/async.py index df9fc89b86..e2a695a924 100644 --- a/sdk/python/examples/11_cli_mini_app/async.py +++ b/sdk/python/examples/11_cli_mini_app/async.py @@ -13,7 +13,6 @@ import asyncio from openai_codex import ( AsyncCodex, - TextInput, ) from openai_codex.types import ( ThreadTokenUsageUpdatedNotification, @@ -51,7 +50,7 @@ async def main() -> None: if user_input in {"/exit", "/quit"}: break - turn = await thread.turn(TextInput(user_input)) + turn = await thread.turn(user_input) usage = None status = None error = None diff --git a/sdk/python/examples/11_cli_mini_app/sync.py b/sdk/python/examples/11_cli_mini_app/sync.py index fd16f930dc..0bf906647c 100644 --- a/sdk/python/examples/11_cli_mini_app/sync.py +++ b/sdk/python/examples/11_cli_mini_app/sync.py @@ -11,7 +11,6 @@ ensure_local_sdk_src() from openai_codex import ( Codex, - TextInput, ) from openai_codex.types import ( ThreadTokenUsageUpdatedNotification, @@ -46,7 +45,7 @@ with Codex(config=runtime_config()) as codex: if user_input in {"/exit", "/quit"}: break - turn = thread.turn(TextInput(user_input)) + turn = thread.turn(user_input) usage = None status = None error = None diff --git a/sdk/python/examples/12_turn_params_kitchen_sink/async.py b/sdk/python/examples/12_turn_params_kitchen_sink/async.py index 258d674a88..f44ea229b0 100644 --- a/sdk/python/examples/12_turn_params_kitchen_sink/async.py +++ b/sdk/python/examples/12_turn_params_kitchen_sink/async.py @@ -14,7 +14,6 @@ import asyncio from openai_codex import ( AsyncCodex, - TextInput, ) from openai_codex.types import ( Personality, @@ -49,7 +48,7 @@ async def main() -> None: ) turn = await thread.turn( - TextInput(PROMPT), + PROMPT, output_schema=OUTPUT_SCHEMA, personality=Personality.pragmatic, summary=SUMMARY, diff --git a/sdk/python/examples/12_turn_params_kitchen_sink/sync.py b/sdk/python/examples/12_turn_params_kitchen_sink/sync.py index feced36f20..160a9bf187 100644 --- a/sdk/python/examples/12_turn_params_kitchen_sink/sync.py +++ b/sdk/python/examples/12_turn_params_kitchen_sink/sync.py @@ -12,7 +12,6 @@ ensure_local_sdk_src() from openai_codex import ( Codex, - TextInput, ) from openai_codex.types import ( Personality, @@ -43,7 +42,7 @@ with Codex(config=runtime_config()) as codex: thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) turn = thread.turn( - TextInput(PROMPT), + PROMPT, output_schema=OUTPUT_SCHEMA, personality=Personality.pragmatic, summary=SUMMARY, diff --git a/sdk/python/examples/13_model_select_and_turn_params/async.py b/sdk/python/examples/13_model_select_and_turn_params/async.py index 31a0c9c243..144169c46c 100644 --- a/sdk/python/examples/13_model_select_and_turn_params/async.py +++ b/sdk/python/examples/13_model_select_and_turn_params/async.py @@ -13,7 +13,6 @@ import asyncio from openai_codex import ( AsyncCodex, - TextInput, ) from openai_codex.types import ( Personality, @@ -91,7 +90,7 @@ async def main() -> None: ) first_turn = await thread.turn( - TextInput("Give one short sentence about reliable production releases."), + "Give one short sentence about reliable production releases.", model=selected_model.model, effort=selected_effort, ) @@ -101,7 +100,7 @@ async def main() -> None: print("items:", len(first.items)) second_turn = await thread.turn( - TextInput("Return JSON for a safe feature-flag rollout plan."), + "Return JSON for a safe feature-flag rollout plan.", cwd=str(Path.cwd()), effort=selected_effort, model=selected_model.model, diff --git a/sdk/python/examples/13_model_select_and_turn_params/sync.py b/sdk/python/examples/13_model_select_and_turn_params/sync.py index 9252f3913c..962665363f 100644 --- a/sdk/python/examples/13_model_select_and_turn_params/sync.py +++ b/sdk/python/examples/13_model_select_and_turn_params/sync.py @@ -11,7 +11,6 @@ ensure_local_sdk_src() from openai_codex import ( Codex, - TextInput, ) from openai_codex.types import ( Personality, @@ -88,7 +87,7 @@ with Codex(config=runtime_config()) as codex: ) first = thread.turn( - TextInput("Give one short sentence about reliable production releases."), + "Give one short sentence about reliable production releases.", model=selected_model.model, effort=selected_effort, ).run() @@ -97,7 +96,7 @@ with Codex(config=runtime_config()) as codex: print("items:", len(first.items)) second = thread.turn( - TextInput("Return JSON for a safe feature-flag rollout plan."), + "Return JSON for a safe feature-flag rollout plan.", cwd=str(Path.cwd()), effort=selected_effort, model=selected_model.model, diff --git a/sdk/python/examples/14_turn_controls/async.py b/sdk/python/examples/14_turn_controls/async.py index f044dadbbe..86e8def04f 100644 --- a/sdk/python/examples/14_turn_controls/async.py +++ b/sdk/python/examples/14_turn_controls/async.py @@ -11,7 +11,7 @@ ensure_local_sdk_src() import asyncio -from openai_codex import AsyncCodex, TextInput +from openai_codex import AsyncCodex async def main() -> None: @@ -19,10 +19,8 @@ async def main() -> None: thread = await codex.thread_start( model="gpt-5.4", config={"model_reasoning_effort": "high"} ) - steer_turn = await thread.turn( - TextInput("Count from 1 to 40 with commas, then one summary sentence.") - ) - steer_result = await steer_turn.steer(TextInput("Keep it brief and stop after 10 numbers.")) + steer_turn = await thread.turn("Count from 1 to 40 with commas, then one summary sentence.") + steer_result = await steer_turn.steer("Keep it brief and stop after 10 numbers.") steer_event_count = 0 steer_completed_status = None @@ -40,7 +38,7 @@ async def main() -> None: steer_preview = "".join(steer_deltas).strip() interrupt_turn = await thread.turn( - TextInput("Count from 1 to 200 with commas, then one summary sentence.") + "Count from 1 to 200 with commas, then one summary sentence." ) interrupt_result = await interrupt_turn.interrupt() diff --git a/sdk/python/examples/14_turn_controls/sync.py b/sdk/python/examples/14_turn_controls/sync.py index 6b6d046a43..c8f1a75e28 100644 --- a/sdk/python/examples/14_turn_controls/sync.py +++ b/sdk/python/examples/14_turn_controls/sync.py @@ -9,14 +9,12 @@ from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() -from openai_codex import Codex, TextInput +from openai_codex import Codex with Codex(config=runtime_config()) as codex: thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) - steer_turn = thread.turn( - TextInput("Count from 1 to 40 with commas, then one summary sentence.") - ) - steer_result = steer_turn.steer(TextInput("Keep it brief and stop after 10 numbers.")) + steer_turn = thread.turn("Count from 1 to 40 with commas, then one summary sentence.") + steer_result = steer_turn.steer("Keep it brief and stop after 10 numbers.") steer_event_count = 0 steer_completed_status = None @@ -33,9 +31,7 @@ with Codex(config=runtime_config()) as codex: raise RuntimeError("stream ended without turn/completed") steer_preview = "".join(steer_deltas).strip() - interrupt_turn = thread.turn( - TextInput("Count from 1 to 200 with commas, then one summary sentence.") - ) + interrupt_turn = thread.turn("Count from 1 to 200 with commas, then one summary sentence.") interrupt_result = interrupt_turn.interrupt() interrupt_event_count = 0 diff --git a/sdk/python/examples/README.md b/sdk/python/examples/README.md index bef1a16c0b..eef3893721 100644 --- a/sdk/python/examples/README.md +++ b/sdk/python/examples/README.md @@ -8,6 +8,9 @@ Each example folder contains runnable versions: All examples intentionally use only public SDK exports from `openai_codex` and `openai_codex.types`. +Examples use plain strings for text-only turns and typed input objects for +multimodal or structured input lists. + ## Prerequisites - Python `>=3.10` diff --git a/sdk/python/notebooks/sdk_walkthrough.ipynb b/sdk/python/notebooks/sdk_walkthrough.ipynb index 1eb80b3a71..bd313a02b1 100644 --- a/sdk/python/notebooks/sdk_walkthrough.ipynb +++ b/sdk/python/notebooks/sdk_walkthrough.ipynb @@ -12,6 +12,7 @@ { "cell_type": "code", "execution_count": null, + "id": "1b6614a5", "metadata": {}, "outputs": [], "source": [ @@ -97,6 +98,7 @@ { "cell_type": "code", "execution_count": null, + "id": "137a6d64", "metadata": {}, "outputs": [], "source": [ @@ -115,6 +117,7 @@ { "cell_type": "code", "execution_count": null, + "id": "5fae892d", "metadata": {}, "outputs": [], "source": [ @@ -122,46 +125,42 @@ "with Codex() as codex:\n", " # Open this URL and call `wait()` without canceling when completing login for real.\n", " login = codex.login_chatgpt()\n", - " canceled = login.cancel()\n", + " print('Please complete login at:', login.auth_url)\n", " completed = login.wait()\n", " account = codex.account()\n", "\n", " print('login.id:', login.login_id)\n", " print('login.auth_url:', login.auth_url)\n", - " print('login.cancel.status:', canceled.status)\n", " print('login.completed.success:', completed.success)\n", - " print('account.requires_openai_auth:', account.requires_openai_auth)\n" + " print('account:', account.email)\n" ] }, { "cell_type": "code", "execution_count": null, + "id": "ebdc04d9", "metadata": {}, "outputs": [], "source": [ "# Cell 3: simple sync conversation\n", "with Codex() as codex:\n", " thread = codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n", - " turn = thread.turn(TextInput('Explain gradient descent in 3 bullets.'))\n", - " result = turn.run()\n", - " print('server:', server_label(codex.metadata))\n", - " print('status:', result.status)\n", - " print(result.final_response)\n", - " print('items:', len(result.items))\n" + " result = thread.run('Explain gradient descent in 3 bullets.')\n", + " print(result.final_response)\n" ] }, { "cell_type": "code", "execution_count": null, + "id": "bb4abb96", "metadata": {}, "outputs": [], "source": [ "# Cell 4: multi-turn continuity in same thread\n", "with Codex() as codex:\n", " thread = codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n", - "\n", - " first = thread.turn(TextInput('Give a short summary of transformers.')).run()\n", - " second = thread.turn(TextInput('Now explain that to a high-school student.')).run()\n", + " first = thread.turn('Give a short summary of transformers.').run()\n", + " second = thread.turn('Now explain that to a high-school student.').run()\n", " print('first status:', first.status)\n", " print('second status:', second.status)\n", " print('second text:', second.final_response)\n" @@ -170,14 +169,15 @@ { "cell_type": "code", "execution_count": null, + "id": "8b0c80fd", "metadata": {}, "outputs": [], "source": [ "# Cell 5: full thread lifecycle and branching (sync)\n", "with Codex() as codex:\n", " thread = codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n", - " first = thread.turn(TextInput('One sentence about structured planning.')).run()\n", - " second = thread.turn(TextInput('Now restate it for a junior engineer.')).run()\n", + " first = thread.turn('One sentence about structured planning.').run()\n", + " second = thread.turn('Now restate it for a junior engineer.').run()\n", "\n", " reopened = codex.thread_resume(thread.id)\n", " listing_active = codex.thread_list(limit=20, archived=False)\n", @@ -193,10 +193,10 @@ " model='gpt-5.4',\n", " config={'model_reasoning_effort': 'high'},\n", " )\n", - " resumed_result = resumed.turn(TextInput('Continue in one short sentence.')).run()\n", + " resumed_result = resumed.turn('Continue in one short sentence.').run()\n", "\n", " forked = codex.thread_fork(unarchived.id, model='gpt-5.4')\n", - " forked_result = forked.turn(TextInput('Take a different angle in one short sentence.')).run()\n", + " forked_result = forked.turn('Take a different angle in one short sentence.').run()\n", "\n", " compact_result = unarchived.compact()\n", "\n", @@ -214,6 +214,7 @@ { "cell_type": "code", "execution_count": null, + "id": "310db8c0", "metadata": {}, "outputs": [], "source": [ @@ -242,7 +243,7 @@ "with Codex() as codex:\n", " thread = codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n", " turn = thread.turn(\n", - " TextInput('Propose a safe production feature-flag rollout. Return JSON matching the schema.'),\n", + " 'Propose a safe production feature-flag rollout. Return JSON matching the schema.',\n", " cwd=str(Path.cwd()),\n", " effort=ReasoningEffort.medium,\n", " model='gpt-5.4',\n", @@ -259,6 +260,7 @@ { "cell_type": "code", "execution_count": null, + "id": "7a33c97d", "metadata": {}, "outputs": [], "source": [ @@ -321,7 +323,7 @@ " thread = codex.thread_start(model=selected_model.model, config={'model_reasoning_effort': selected_effort.value})\n", "\n", " first = thread.turn(\n", - " TextInput('Give one short sentence about reliable production releases.'),\n", + " 'Give one short sentence about reliable production releases.',\n", " model=selected_model.model,\n", " effort=selected_effort,\n", " ).run()\n", @@ -329,7 +331,7 @@ " print('items:', len(first.items))\n", "\n", " second = thread.turn(\n", - " TextInput('Return JSON for a safe feature-flag rollout plan.'),\n", + " 'Return JSON for a safe feature-flag rollout plan.',\n", " cwd=str(Path.cwd()),\n", " effort=selected_effort,\n", " model=selected_model.model,\n", @@ -345,6 +347,7 @@ { "cell_type": "code", "execution_count": null, + "id": "e9aef26a", "metadata": {}, "outputs": [], "source": [ @@ -364,6 +367,7 @@ { "cell_type": "code", "execution_count": null, + "id": "a0cecc6c", "metadata": {}, "outputs": [], "source": [ @@ -382,6 +386,7 @@ { "cell_type": "code", "execution_count": null, + "id": "91afa2b8", "metadata": {}, "outputs": [], "source": [ @@ -390,7 +395,7 @@ " thread = codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n", "\n", " result = retry_on_overload(\n", - " lambda: thread.turn(TextInput('List 5 failure modes in distributed systems.')).run(),\n", + " lambda: thread.turn('List 5 failure modes in distributed systems.').run(),\n", " max_attempts=3,\n", " initial_delay_s=0.25,\n", " max_delay_s=2.0,\n", @@ -402,6 +407,7 @@ { "cell_type": "code", "execution_count": null, + "id": "103be934", "metadata": {}, "outputs": [], "source": [ @@ -412,8 +418,8 @@ "async def async_lifecycle_demo():\n", " async with AsyncCodex() as codex:\n", " thread = await codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n", - " first = await (await thread.turn(TextInput('One sentence about structured planning.'))).run()\n", - " second = await (await thread.turn(TextInput('Now restate it for a junior engineer.'))).run()\n", + " first = await (await thread.turn('One sentence about structured planning.')).run()\n", + " second = await (await thread.turn('Now restate it for a junior engineer.')).run()\n", "\n", " reopened = await codex.thread_resume(thread.id)\n", " listing_active = await codex.thread_list(limit=20, archived=False)\n", @@ -429,10 +435,10 @@ " model='gpt-5.4',\n", " config={'model_reasoning_effort': 'high'},\n", " )\n", - " resumed_result = await (await resumed.turn(TextInput('Continue in one short sentence.'))).run()\n", + " resumed_result = await (await resumed.turn('Continue in one short sentence.')).run()\n", "\n", " forked = await codex.thread_fork(unarchived.id, model='gpt-5.4')\n", - " forked_result = await (await forked.turn(TextInput('Take a different angle in one short sentence.'))).run()\n", + " forked_result = await (await forked.turn('Take a different angle in one short sentence.')).run()\n", "\n", " compact_result = await unarchived.compact()\n", "\n", @@ -453,6 +459,7 @@ { "cell_type": "code", "execution_count": null, + "id": "365aa10c", "metadata": {}, "outputs": [], "source": [ @@ -463,9 +470,9 @@ "async def async_stream_demo():\n", " async with AsyncCodex() as codex:\n", " thread = await codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n", - " steer_turn = await thread.turn(TextInput('Count from 1 to 40 with commas, then one summary sentence.'))\n", + " steer_turn = await thread.turn('Count from 1 to 40 with commas, then one summary sentence.')\n", "\n", - " steer_result = await steer_turn.steer(TextInput('Keep it brief and stop after 10 numbers.'))\n", + " steer_result = await steer_turn.steer('Keep it brief and stop after 10 numbers.')\n", "\n", " steer_event_count = 0\n", " steer_completed_status = None\n", @@ -482,7 +489,7 @@ " raise RuntimeError('stream ended without turn/completed')\n", " steer_preview = ''.join(steer_deltas).strip()\n", "\n", - " interrupt_turn = await thread.turn(TextInput('Count from 1 to 200 with commas, then one summary sentence.'))\n", + " interrupt_turn = await thread.turn('Count from 1 to 200 with commas, then one summary sentence.')\n", " interrupt_result = await interrupt_turn.interrupt()\n", "\n", " interrupt_event_count = 0\n", @@ -516,13 +523,21 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": ".venv", "language": "python", "name": "python3" }, "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", "name": "python", - "version": "3.10+" + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.3" } }, "nbformat": 4, diff --git a/sdk/python/scripts/update_sdk_artifacts.py b/sdk/python/scripts/update_sdk_artifacts.py index a40a4aa9c3..5cad14cf5e 100755 --- a/sdk/python/scripts/update_sdk_artifacts.py +++ b/sdk/python/scripts/update_sdk_artifacts.py @@ -1040,12 +1040,12 @@ def _render_thread_block( lines = [ " def turn(", " self,", - " input: Input,", + " input: RunInput,", " *,", *_approval_mode_override_signature_lines(), *_kw_signature_lines(turn_fields), " ) -> TurnHandle:", - " wire_input = _to_wire_input(input)", + " wire_input = _to_wire_input(_normalize_run_input(input))", _approval_mode_assignment_line("_approval_mode_override_settings"), " params = TurnStartParams(", " thread_id=self.id,", @@ -1065,13 +1065,13 @@ def _render_async_thread_block( lines = [ " async def turn(", " self,", - " input: Input,", + " input: RunInput,", " *,", *_approval_mode_override_signature_lines(), *_kw_signature_lines(turn_fields), " ) -> AsyncTurnHandle:", " await self._codex._ensure_initialized()", - " wire_input = _to_wire_input(input)", + " wire_input = _to_wire_input(_normalize_run_input(input))", _approval_mode_assignment_line("_approval_mode_override_settings"), " params = TurnStartParams(", " thread_id=self.id,", diff --git a/sdk/python/src/openai_codex/__init__.py b/sdk/python/src/openai_codex/__init__.py index b3db7e3175..3bc018a769 100644 --- a/sdk/python/src/openai_codex/__init__.py +++ b/sdk/python/src/openai_codex/__init__.py @@ -14,6 +14,7 @@ from .api import ( InputItem, LocalImageInput, MentionInput, + RunInput, SkillInput, TextInput, Thread, @@ -54,6 +55,7 @@ __all__ = [ "TurnResult", "Input", "InputItem", + "RunInput", "TextInput", "ImageInput", "LocalImageInput", diff --git a/sdk/python/src/openai_codex/api.py b/sdk/python/src/openai_codex/api.py index 347f95eeae..f14a311789 100644 --- a/sdk/python/src/openai_codex/api.py +++ b/sdk/python/src/openai_codex/api.py @@ -12,7 +12,7 @@ from ._approval_mode import ( from ._initialize_metadata import validate_initialize_metadata from ._inputs import ( ImageInput as ImageInput, - Input, + Input as Input, InputItem as InputItem, LocalImageInput as LocalImageInput, MentionInput as MentionInput, @@ -534,7 +534,7 @@ class Thread: summary: ReasoningSummary | None = None, ) -> TurnResult: turn = self.turn( - _normalize_run_input(input), + input, approval_mode=approval_mode, cwd=cwd, effort=effort, @@ -554,7 +554,7 @@ class Thread: # BEGIN GENERATED: Thread.flat_methods def turn( self, - input: Input, + input: RunInput, *, approval_mode: ApprovalMode | None = None, cwd: str | None = None, @@ -566,7 +566,7 @@ class Thread: service_tier: str | None = None, summary: ReasoningSummary | None = None, ) -> TurnHandle: - wire_input = _to_wire_input(input) + wire_input = _to_wire_input(_normalize_run_input(input)) approval_policy, approvals_reviewer = _approval_mode_override_settings(approval_mode) params = TurnStartParams( thread_id=self.id, @@ -617,7 +617,7 @@ class AsyncThread: summary: ReasoningSummary | None = None, ) -> TurnResult: turn = await self.turn( - _normalize_run_input(input), + input, approval_mode=approval_mode, cwd=cwd, effort=effort, @@ -637,7 +637,7 @@ class AsyncThread: # BEGIN GENERATED: AsyncThread.flat_methods async def turn( self, - input: Input, + input: RunInput, *, approval_mode: ApprovalMode | None = None, cwd: str | None = None, @@ -650,7 +650,7 @@ class AsyncThread: summary: ReasoningSummary | None = None, ) -> AsyncTurnHandle: await self._codex._ensure_initialized() - wire_input = _to_wire_input(input) + wire_input = _to_wire_input(_normalize_run_input(input)) approval_policy, approvals_reviewer = _approval_mode_override_settings(approval_mode) params = TurnStartParams( thread_id=self.id, @@ -694,8 +694,12 @@ class TurnHandle: thread_id: str id: str - def steer(self, input: Input) -> TurnSteerResponse: - return self._client.turn_steer(self.thread_id, self.id, _to_wire_input(input)) + def steer(self, input: RunInput) -> TurnSteerResponse: + return self._client.turn_steer( + self.thread_id, + self.id, + _to_wire_input(_normalize_run_input(input)), + ) def interrupt(self) -> TurnInterruptResponse: return self._client.turn_interrupt(self.thread_id, self.id) @@ -730,12 +734,12 @@ class AsyncTurnHandle: thread_id: str id: str - async def steer(self, input: Input) -> TurnSteerResponse: + async def steer(self, input: RunInput) -> TurnSteerResponse: await self._codex._ensure_initialized() return await self._codex._client.turn_steer( self.thread_id, self.id, - _to_wire_input(input), + _to_wire_input(_normalize_run_input(input)), ) async def interrupt(self) -> TurnInterruptResponse: diff --git a/sdk/python/tests/test_app_server_streaming.py b/sdk/python/tests/test_app_server_streaming.py index 02d089f2f1..3b79d9def3 100644 --- a/sdk/python/tests/test_app_server_streaming.py +++ b/sdk/python/tests/test_app_server_streaming.py @@ -11,7 +11,7 @@ from app_server_helpers import ( streaming_response, ) -from openai_codex import AsyncCodex, Codex, TextInput +from openai_codex import AsyncCodex, Codex from openai_codex.generated.v2_all import ( AgentMessageDeltaNotification, TurnCompletedNotification, @@ -26,8 +26,9 @@ def test_sync_stream_routes_text_deltas_and_completion(tmp_path) -> None: with Codex(config=harness.app_server_config()) as codex: thread = codex.thread_start() - stream = thread.turn(TextInput("stream please")).stream() + stream = thread.turn("stream please").stream() events = list(stream) + request = harness.responses.single_request() assert { "deltas": [ @@ -36,6 +37,7 @@ def test_sync_stream_routes_text_deltas_and_completion(tmp_path) -> None: if isinstance(event.payload, AgentMessageDeltaNotification) ], "agent_messages": agent_message_texts(events), + "request_user_texts": request.message_input_texts("user")[-1:], "completed_statuses": [ event.payload.turn.status for event in events @@ -44,6 +46,7 @@ def test_sync_stream_routes_text_deltas_and_completion(tmp_path) -> None: } == { "deltas": ["he", "llo"], "agent_messages": ["hello"], + "request_user_texts": ["stream please"], "completed_statuses": [TurnStatus.completed], } @@ -55,7 +58,7 @@ def test_turn_run_returns_completed_turn(tmp_path) -> None: with Codex(config=harness.app_server_config()) as codex: thread = codex.thread_start() - turn = thread.turn(TextInput("complete this turn")) + turn = thread.turn("complete this turn") completed = turn.run() assert { @@ -83,8 +86,9 @@ def test_async_stream_routes_text_deltas_and_completion(tmp_path) -> None: async with AsyncCodex(config=harness.app_server_config()) as codex: thread = await codex.thread_start() - turn = await thread.turn(TextInput("async stream please")) + turn = await thread.turn("async stream please") events = [event async for event in turn.stream()] + request = harness.responses.single_request() assert { "deltas": [ @@ -93,6 +97,7 @@ def test_async_stream_routes_text_deltas_and_completion(tmp_path) -> None: if isinstance(event.payload, AgentMessageDeltaNotification) ], "agent_messages": agent_message_texts(events), + "request_user_texts": request.message_input_texts("user")[-1:], "completed_statuses": [ event.payload.turn.status for event in events @@ -101,6 +106,7 @@ def test_async_stream_routes_text_deltas_and_completion(tmp_path) -> None: } == { "deltas": ["as", "ync"], "agent_messages": ["async"], + "request_user_texts": ["async stream please"], "completed_statuses": [TurnStatus.completed], } @@ -178,8 +184,8 @@ def test_interleaved_sync_turn_streams_route_by_turn_id(tmp_path) -> None: with Codex(config=harness.app_server_config()) as codex: first_thread = codex.thread_start() second_thread = codex.thread_start() - first_turn = first_thread.turn(TextInput("first")) - second_turn = second_thread.turn(TextInput("second")) + first_turn = first_thread.turn("first") + second_turn = second_thread.turn("second") first_stream = first_turn.stream() second_stream = second_turn.stream() @@ -231,8 +237,8 @@ def test_interleaved_async_turn_streams_route_by_turn_id(tmp_path) -> None: async with AsyncCodex(config=harness.app_server_config()) as codex: first_thread = await codex.thread_start() second_thread = await codex.thread_start() - first_turn = await first_thread.turn(TextInput("async first")) - second_turn = await second_thread.turn(TextInput("async second")) + first_turn = await first_thread.turn("async first") + second_turn = await second_thread.turn("async second") first_stream = first_turn.stream() second_stream = second_turn.stream() diff --git a/sdk/python/tests/test_app_server_turn_controls.py b/sdk/python/tests/test_app_server_turn_controls.py index af02f6b74f..dd994b88f9 100644 --- a/sdk/python/tests/test_app_server_turn_controls.py +++ b/sdk/python/tests/test_app_server_turn_controls.py @@ -3,7 +3,7 @@ from __future__ import annotations from app_server_harness import AppServerHarness from app_server_helpers import agent_message_texts, streaming_response -from openai_codex import Codex, TextInput +from openai_codex import Codex from openai_codex.generated.v2_all import TurnStatus @@ -21,9 +21,9 @@ def test_turn_steer_adds_follow_up_input(tmp_path) -> None: with Codex(config=harness.app_server_config()) as codex: thread = codex.thread_start() - turn = thread.turn(TextInput("Start a steerable turn.")) + turn = thread.turn("Start a steerable turn.") harness.responses.wait_for_requests(1) - steer = turn.steer(TextInput("Use this steering input.")) + steer = turn.steer("Use this steering input.") events = list(turn.stream()) requests = harness.responses.wait_for_requests(2) @@ -61,7 +61,7 @@ def test_turn_interrupt_stops_active_turn_and_follow_up_runs(tmp_path) -> None: with Codex(config=harness.app_server_config()) as codex: thread = codex.thread_start() - interrupted_turn = thread.turn(TextInput("Start a long turn.")) + interrupted_turn = thread.turn("Start a long turn.") harness.responses.wait_for_requests(1) interrupt_response = interrupted_turn.interrupt() completed = interrupted_turn.run() diff --git a/sdk/python/tests/test_public_api_signatures.py b/sdk/python/tests/test_public_api_signatures.py index c7a00d3897..9a1212dfdf 100644 --- a/sdk/python/tests/test_public_api_signatures.py +++ b/sdk/python/tests/test_public_api_signatures.py @@ -40,6 +40,7 @@ EXPECTED_ROOT_EXPORTS = [ "TurnResult", "Input", "InputItem", + "RunInput", "TextInput", "ImageInput", "LocalImageInput", @@ -166,6 +167,22 @@ def test_turn_run_methods_return_turn_result() -> None: ) +def test_turn_input_methods_accept_string_shortcut() -> None: + """Every public turn-input method should accept strings and typed inputs.""" + funcs = [ + Thread.run, + Thread.turn, + AsyncThread.run, + AsyncThread.turn, + TurnHandle.steer, + AsyncTurnHandle.steer, + ] + + assert {fn: inspect.signature(fn).parameters["input"].annotation for fn in funcs} == ( + dict.fromkeys(funcs, "RunInput") + ) + + def test_root_exports_approval_mode() -> None: """The root package should expose the high-level approval mode enum.""" assert [(mode.name, mode.value) for mode in ApprovalMode] == [ diff --git a/sdk/python/tests/test_real_app_server_integration.py b/sdk/python/tests/test_real_app_server_integration.py index 08f07c986c..5c4ec83fd4 100644 --- a/sdk/python/tests/test_real_app_server_integration.py +++ b/sdk/python/tests/test_real_app_server_integration.py @@ -234,14 +234,14 @@ def test_real_thread_and_turn_start_smoke(runtime_env: PreparedRuntimeEnv) -> No textwrap.dedent( """ import json - from openai_codex import Codex, TextInput + from openai_codex import Codex with Codex() as codex: thread = codex.thread_start( model="gpt-5.4", config={"model_reasoning_effort": "high"}, ) - result = thread.turn(TextInput("hello")).run() + result = thread.turn("hello").run() print(json.dumps({ "thread_id": thread.id, "turn_id": result.id, @@ -331,7 +331,7 @@ def test_real_async_thread_turn_usage_and_ids_smoke( """ import asyncio import json - from openai_codex import AsyncCodex, TextInput + from openai_codex import AsyncCodex async def main(): async with AsyncCodex() as codex: @@ -339,7 +339,7 @@ def test_real_async_thread_turn_usage_and_ids_smoke( model="gpt-5.4", config={"model_reasoning_effort": "high"}, ) - result = await (await thread.turn(TextInput("say ok"))).run() + result = await (await thread.turn("say ok")).run() print(json.dumps({ "thread_id": thread.id, "turn_id": result.id, @@ -458,14 +458,14 @@ def test_real_streaming_smoke_turn_completed(runtime_env: PreparedRuntimeEnv) -> textwrap.dedent( """ import json - from openai_codex import Codex, TextInput + from openai_codex import Codex with Codex() as codex: thread = codex.thread_start( model="gpt-5.4", config={"model_reasoning_effort": "high"}, ) - turn = thread.turn(TextInput("Reply with one short sentence.")) + turn = thread.turn("Reply with one short sentence.") saw_delta = False saw_completed = False for event in turn.stream(): @@ -491,16 +491,16 @@ def test_real_turn_interrupt_smoke(runtime_env: PreparedRuntimeEnv) -> None: textwrap.dedent( """ import json - from openai_codex import Codex, TextInput + from openai_codex import Codex with Codex() as codex: thread = codex.thread_start( model="gpt-5.4", config={"model_reasoning_effort": "high"}, ) - turn = thread.turn(TextInput("Count from 1 to 200 with commas.")) + turn = thread.turn("Count from 1 to 200 with commas.") turn.interrupt() - follow_up = thread.turn(TextInput("Say 'ok' only.")).run() + follow_up = thread.turn("Say 'ok' only.").run() print(json.dumps({"status": follow_up.status.value})) """ ), From 64ead6a83a6ed348229bc98a9b5d8b0c550d8305 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Sun, 17 May 2026 20:07:32 -0700 Subject: [PATCH 5/6] Add exec-server websocket keepalive (#23226) ## Summary - send periodic websocket Ping frames from outbound exec-server websocket clients - cover direct exec-server websocket clients plus rendezvous harness/executor websocket connections - keep inbound axum-accepted exec-server websocket connections passive - add focused keepalive coverage for direct and relay websocket paths ## Validation - /Users/starr/code/openai/project/dotslash-gen/bin/bazel test //codex-rs/exec-server:exec-server-unit-tests --test_filter='websocket_connection_sends_keepalive_ping|harness_connection_sends_keepalive_ping|multiplexed_executor_sends_keepalive_ping' - /Users/starr/code/openai/project/dotslash-gen/bin/bazel test //codex-rs/exec-server:exec-server-relay-test --test_filter=multiplexed_remote_executor_routes_independent_virtual_streams --- codex-rs/exec-server/src/connection.rs | 191 ++++++++++++++++++++++--- codex-rs/exec-server/src/relay.rs | 159 +++++++++++++++++--- 2 files changed, 304 insertions(+), 46 deletions(-) diff --git a/codex-rs/exec-server/src/connection.rs b/codex-rs/exec-server/src/connection.rs index d5ff1f966f..cf504bbea7 100644 --- a/codex-rs/exec-server/src/connection.rs +++ b/codex-rs/exec-server/src/connection.rs @@ -30,6 +30,10 @@ use tokio::io::BufWriter; pub(crate) const CHANNEL_CAPACITY: usize = 128; const STDIO_TERMINATION_GRACE_PERIOD: Duration = Duration::from_secs(2); +#[cfg(test)] +pub(crate) const WEBSOCKET_KEEPALIVE_INTERVAL: Duration = Duration::from_millis(25); +#[cfg(not(test))] +pub(crate) const WEBSOCKET_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(30); #[derive(Debug)] pub(crate) enum JsonRpcConnectionEvent { @@ -320,18 +324,32 @@ impl JsonRpcConnection { S: AsyncRead + AsyncWrite + Unpin + Send + 'static, { let (websocket_writer, websocket_reader) = stream.split(); - Self::from_websocket_parts(websocket_writer, websocket_reader, connection_label) + Self::from_websocket_parts( + websocket_writer, + websocket_reader, + connection_label, + Some(WEBSOCKET_KEEPALIVE_INTERVAL), + ) } pub(crate) fn from_axum_websocket(stream: AxumWebSocket, connection_label: String) -> Self { let (websocket_writer, websocket_reader) = stream.split(); - Self::from_websocket_parts(websocket_writer, websocket_reader, connection_label) + Self::from_websocket_parts( + websocket_writer, + websocket_reader, + connection_label, + // Axum only wraps inbound exec-server websocket accepts. Outbound websocket clients + // own keepalive pings so one side does not accidentally create redundant traffic. + /*keepalive_interval*/ + None, + ) } fn from_websocket_parts( mut websocket_writer: W, mut websocket_reader: R, connection_label: String, + keepalive_interval: Option, ) -> Self where W: Sink + Unpin + Send + 'static, @@ -404,30 +422,54 @@ impl JsonRpcConnection { }); let writer_task = tokio::spawn(async move { - while let Some(message) = outgoing_rx.recv().await { - match serialize_jsonrpc_message(&message) { - Ok(encoded) => { - if let Err(err) = websocket_writer.send(M::from_text(encoded)).await { - send_disconnected( - &incoming_tx, - &disconnected_tx, - Some(format!( - "failed to write websocket JSON-RPC message to {connection_label}: {err}" - )), + if let Some(keepalive_interval) = keepalive_interval { + let mut keepalive = tokio::time::interval_at( + tokio::time::Instant::now() + keepalive_interval, + keepalive_interval, + ); + keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + maybe_message = outgoing_rx.recv() => { + let Some(message) = maybe_message else { + break; + }; + if let Err(reason) = send_websocket_jsonrpc_message( + &mut websocket_writer, + &connection_label, + &message, ) - .await; - break; + .await + { + send_disconnected(&incoming_tx, &disconnected_tx, Some(reason)).await; + break; + } + } + _ = keepalive.tick() => { + if let Err(err) = websocket_writer.send(M::ping()).await { + send_disconnected( + &incoming_tx, + &disconnected_tx, + Some(format!( + "failed to write websocket ping to {connection_label}: {err}" + )), + ) + .await; + break; + } } } - Err(err) => { - send_disconnected( - &incoming_tx, - &disconnected_tx, - Some(format!( - "failed to serialize JSON-RPC message for {connection_label}: {err}" - )), - ) - .await; + } + } else { + while let Some(message) = outgoing_rx.recv().await { + if let Err(reason) = send_websocket_jsonrpc_message( + &mut websocket_writer, + &connection_label, + &message, + ) + .await + { + send_disconnected(&incoming_tx, &disconnected_tx, Some(reason)).await; break; } } @@ -458,6 +500,7 @@ enum JsonRpcWebSocketFrame { trait JsonRpcWebSocketMessage: Send + 'static { fn parse_jsonrpc_frame(self) -> Result; fn from_text(text: String) -> Self; + fn ping() -> Self; } impl JsonRpcWebSocketMessage for Message { @@ -479,6 +522,10 @@ impl JsonRpcWebSocketMessage for Message { fn from_text(text: String) -> Self { Self::Text(text.into()) } + + fn ping() -> Self { + Self::Ping(Vec::new().into()) + } } impl JsonRpcWebSocketMessage for AxumWebSocketMessage { @@ -500,6 +547,10 @@ impl JsonRpcWebSocketMessage for AxumWebSocketMessage { fn from_text(text: String) -> Self { Self::Text(text.into()) } + + fn ping() -> Self { + Self::Ping(Vec::new().into()) + } } async fn send_disconnected( @@ -538,6 +589,100 @@ where writer.flush().await } +async fn send_websocket_jsonrpc_message( + websocket_writer: &mut W, + connection_label: &str, + message: &JSONRPCMessage, +) -> Result<(), String> +where + W: Sink + Unpin, + M: JsonRpcWebSocketMessage, + E: std::fmt::Display, +{ + match serialize_jsonrpc_message(message) { + Ok(encoded) => websocket_writer + .send(M::from_text(encoded)) + .await + .map_err(|err| { + format!("failed to write websocket JSON-RPC message to {connection_label}: {err}") + }), + Err(err) => Err(format!( + "failed to serialize JSON-RPC message for {connection_label}: {err}" + )), + } +} + fn serialize_jsonrpc_message(message: &JSONRPCMessage) -> Result { serde_json::to_string(message) } + +#[cfg(test)] +mod tests { + use std::pin::Pin; + + use futures::channel::mpsc as futures_mpsc; + use futures::stream; + use futures::task::Context; + use futures::task::Poll; + use tokio::time::timeout; + + use super::*; + + struct TestWebSocketSink { + message_tx: futures_mpsc::UnboundedSender, + } + + impl Sink for TestWebSocketSink { + type Error = std::convert::Infallible; + + fn poll_ready( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + + fn start_send(self: Pin<&mut Self>, item: Message) -> Result<(), Self::Error> { + self.get_mut() + .message_tx + .unbounded_send(item) + .expect("test websocket receiver should stay open"); + Ok(()) + } + + fn poll_flush( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_close( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + } + + #[tokio::test] + async fn websocket_connection_sends_keepalive_ping() { + let (message_tx, mut message_rx) = futures_mpsc::unbounded::(); + let websocket_writer = TestWebSocketSink { message_tx }; + let websocket_reader = stream::pending::>(); + let connection = JsonRpcConnection::from_websocket_parts( + websocket_writer, + websocket_reader, + "test".into(), + Some(WEBSOCKET_KEEPALIVE_INTERVAL), + ); + + let message = timeout(Duration::from_secs(1), message_rx.next()) + .await + .expect("keepalive ping should arrive before timeout") + .expect("keepalive ping should be sent"); + assert!(matches!(message, Message::Ping(_))); + + drop(connection); + } +} diff --git a/codex-rs/exec-server/src/relay.rs b/codex-rs/exec-server/src/relay.rs index bce787cfc2..7470a6290c 100644 --- a/codex-rs/exec-server/src/relay.rs +++ b/codex-rs/exec-server/src/relay.rs @@ -19,6 +19,7 @@ use crate::connection::CHANNEL_CAPACITY; use crate::connection::JsonRpcConnection; use crate::connection::JsonRpcConnectionEvent; use crate::connection::JsonRpcTransport; +use crate::connection::WEBSOCKET_KEEPALIVE_INTERVAL; use crate::relay_proto::RelayData; use crate::relay_proto::RelayMessageFrame; use crate::relay_proto::RelayResume; @@ -262,24 +263,42 @@ where return; } + let mut keepalive = tokio::time::interval_at( + tokio::time::Instant::now() + WEBSOCKET_KEEPALIVE_INTERVAL, + WEBSOCKET_KEEPALIVE_INTERVAL, + ); + keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); let mut next_seq = 0u32; - while let Some(message) = outgoing_rx.recv().await { - let payload = match jsonrpc_payload(&message) { - Ok(payload) => payload, - Err(err) => { - warn!("failed to serialize JSON-RPC payload for relay transport: {err}"); - break; + loop { + tokio::select! { + maybe_message = outgoing_rx.recv() => { + let Some(message) = maybe_message else { + break; + }; + let payload = match jsonrpc_payload(&message) { + Ok(payload) => payload, + Err(err) => { + warn!("failed to serialize JSON-RPC payload for relay transport: {err}"); + break; + } + }; + let frame = RelayMessageFrame::data(stream_id.clone(), next_seq, payload); + next_seq = next_seq.wrapping_add(1); + if websocket_writer + .send(Message::Binary(encode_relay_message_frame(&frame).into())) + .await + .is_err() + { + let _ = disconnected_tx.send(true); + break; + } + } + _ = keepalive.tick() => { + if websocket_writer.send(Message::Ping(Vec::new().into())).await.is_err() { + let _ = disconnected_tx.send(true); + break; + } } - }; - let frame = RelayMessageFrame::data(stream_id.clone(), next_seq, payload); - next_seq = next_seq.wrapping_add(1); - if websocket_writer - .send(Message::Binary(encode_relay_message_frame(&frame).into())) - .await - .is_err() - { - let _ = disconnected_tx.send(true); - break; } } }); @@ -303,13 +322,30 @@ pub(crate) async fn run_multiplexed_executor( let (physical_outgoing_tx, mut physical_outgoing_rx) = mpsc::channel::>(CHANNEL_CAPACITY); let writer_task = tokio::spawn(async move { - while let Some(encoded) = physical_outgoing_rx.recv().await { - if websocket_writer - .send(Message::Binary(encoded.into())) - .await - .is_err() - { - break; + let mut keepalive = tokio::time::interval_at( + tokio::time::Instant::now() + WEBSOCKET_KEEPALIVE_INTERVAL, + WEBSOCKET_KEEPALIVE_INTERVAL, + ); + keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + maybe_encoded = physical_outgoing_rx.recv() => { + let Some(encoded) = maybe_encoded else { + break; + }; + if websocket_writer + .send(Message::Binary(encoded.into())) + .await + .is_err() + { + break; + } + } + _ = keepalive.tick() => { + if websocket_writer.send(Message::Ping(Vec::new().into())).await.is_err() { + break; + } + } } } }); @@ -453,3 +489,80 @@ fn spawn_virtual_stream( disconnected_tx, } } + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use tokio::net::TcpListener; + use tokio::time::timeout; + use tokio_tungstenite::accept_async; + use tokio_tungstenite::connect_async; + use tokio_tungstenite::tungstenite::Message; + + use super::*; + + fn test_runtime_paths() -> anyhow::Result { + crate::ExecServerRuntimePaths::new( + std::env::current_exe()?, + /*codex_linux_sandbox_exe*/ None, + ) + .map_err(anyhow::Error::from) + } + + #[tokio::test] + async fn multiplexed_executor_sends_keepalive_ping() -> anyhow::Result<()> { + let (client_websocket, mut server_websocket) = websocket_pair().await?; + let executor_task = tokio::spawn(run_multiplexed_executor( + client_websocket, + ConnectionProcessor::new(test_runtime_paths()?), + )); + + read_keepalive_ping(&mut server_websocket).await?; + + executor_task.abort(); + let _ = executor_task.await; + Ok(()) + } + + #[tokio::test] + async fn harness_connection_sends_keepalive_ping() -> anyhow::Result<()> { + let (client_websocket, mut server_websocket) = websocket_pair().await?; + let connection = harness_connection_from_websocket(client_websocket, "test".to_string()); + + read_keepalive_ping(&mut server_websocket).await?; + + drop(connection); + Ok(()) + } + + async fn websocket_pair() -> anyhow::Result<( + WebSocketStream>, + WebSocketStream, + )> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let websocket_url = format!("ws://{}", listener.local_addr()?); + let server_task = tokio::spawn(async move { + let (stream, _) = listener.accept().await?; + accept_async(stream).await.map_err(anyhow::Error::from) + }); + let (client_websocket, _) = connect_async(websocket_url).await?; + let server_websocket = server_task.await??; + Ok((client_websocket, server_websocket)) + } + + async fn read_keepalive_ping( + websocket: &mut WebSocketStream, + ) -> anyhow::Result<()> { + loop { + let Some(message) = timeout(Duration::from_secs(1), websocket.next()).await? else { + anyhow::bail!("websocket closed before keepalive ping"); + }; + match message? { + Message::Ping(_) => return Ok(()), + Message::Binary(_) | Message::Text(_) | Message::Pong(_) | Message::Frame(_) => {} + Message::Close(_) => anyhow::bail!("websocket closed before keepalive ping"), + } + } + } +} From 22dd9ad3929253ed24d7ee4f10f238e95ab25f37 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 18 May 2026 09:59:34 +0200 Subject: [PATCH 6/6] Densify and version memory summaries (#23148) ## Why `memory_summary.md` is injected into every session, so its value depends on staying compact, navigational, and easy to regenerate when the expected shape changes. The previous consolidation prompt encouraged a broad actionable inventory and allowed older summary structures to be patched in place, which makes it easier for stale or overly verbose summaries to keep accumulating. This change makes the summary format explicitly versioned and biases Phase 2 memory consolidation toward denser prompt-loaded context. ## What changed - Require `memory_summary.md` to begin with an exact `v1` header. - Teach consolidation to regenerate `memory_summary.md` from scratch when the header is missing or incompatible, while still allowing incremental updates to `MEMORY.md`. - Tighten the `memory_summary.md` instructions so it acts as a compact routing/index layer instead of a second handbook. - Lower `MEMORY_TOOL_DEVELOPER_INSTRUCTIONS_SUMMARY_TOKEN_LIMIT` from `5_000` to `2_500` so the runtime prompt budget matches the denser summary target. ## Verification Not run; this is a prompt/template update plus a prompt budget constant change. --- codex-rs/memories/read/src/lib.rs | 2 +- .../write/templates/memories/consolidation.md | 96 +++++++++++++------ 2 files changed, 68 insertions(+), 30 deletions(-) diff --git a/codex-rs/memories/read/src/lib.rs b/codex-rs/memories/read/src/lib.rs index 2f7b7645e4..ac6aa4d7f1 100644 --- a/codex-rs/memories/read/src/lib.rs +++ b/codex-rs/memories/read/src/lib.rs @@ -13,7 +13,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; pub use prompts::build_memory_tool_developer_instructions; -const MEMORY_TOOL_DEVELOPER_INSTRUCTIONS_SUMMARY_TOKEN_LIMIT: usize = 5_000; +const MEMORY_TOOL_DEVELOPER_INSTRUCTIONS_SUMMARY_TOKEN_LIMIT: usize = 2_500; pub fn memory_root(codex_home: &AbsolutePathBuf) -> AbsolutePathBuf { codex_home.join("memories") diff --git a/codex-rs/memories/write/templates/memories/consolidation.md b/codex-rs/memories/write/templates/memories/consolidation.md index cc586bd4c1..0eef7580b5 100644 --- a/codex-rs/memories/write/templates/memories/consolidation.md +++ b/codex-rs/memories/write/templates/memories/consolidation.md @@ -20,8 +20,8 @@ CONTEXT: MEMORY FOLDER STRUCTURE Folder structure (under {{ memory_root }}/): - memory_summary.md - - Always loaded into the system prompt. Must remain informative and highly navigational, - but still discriminative enough to guide retrieval. + - Always loaded into the system prompt. First line must be exactly `v1`. + Must stay dense, highly navigational, and discriminative enough to guide retrieval. - MEMORY.md - Handbook entries. Used to grep for keywords; aggregated insights from rollouts; pointers to rollout summaries if certain past rollouts are very relevant. @@ -133,7 +133,8 @@ Under `{{ memory_root }}/`: - merged memories; produce a lightly clustered version if applicable - `rollout_summaries/*.md` - `memory_summary.md` - - read the existing summary so updates stay consistent + - read the existing summary so updates stay consistent only if its first line is exactly `v1`; + otherwise treat the summary as schema-incompatible and regenerate the whole file from scratch - `skills/*` - read existing skills so updates are incremental and non-duplicative {{ memory_extensions_primary_inputs }} @@ -143,6 +144,8 @@ Mode selection: and `skills/`). - INCREMENTAL UPDATE: existing artifacts already exist and `raw_memories.md` mostly contains new additions. +- Summary schema reset: if `memory_summary.md` is missing, empty, or does not start with exactly + `v1`, regenerate only `memory_summary.md` from scratch after `MEMORY.md` is current. Memory workspace diff: @@ -183,6 +186,8 @@ Rules: - If there is no meaningful signal to add beyond what already exists, keep outputs minimal. - You should always make sure `MEMORY.md` and `memory_summary.md` exist and are up to date. +- `memory_summary.md` must start with the exact line `v1`; if it does not, rewrite the entire + file rather than patching the previous summary in place. - Follow the format and schema of the artifacts below. - Do not target fixed counts (memory blocks, task groups, topics, or bullets). Let the signal determine the granularity and depth. @@ -441,14 +446,40 @@ task group/task` from the full raw inventory so you can have a better overview. - Prefer placing reusable user preferences in `## User preferences` and the rest of the durable know-how in `## Reusable knowledge` and `## Failures and how to do differently`. - Use `memory_summary.md` as the cross-task summary layer, not the place for project-specific - runbooks. It should stay compact in narrative/profile sections, but its `## User preferences` - section is the main actionable payload and may be much longer when that helps future agents - avoid repeated user steering. + runbooks. Its `## User preferences` section is the main actionable payload, but it should + still stay compact, deduplicated, and limited to preferences likely to change future behavior. ============================================================ 2) `memory_summary.md` FORMAT (STRICT) ============================================================ +File header: + +The file must begin exactly: + +```md +v1 + +## User Profile +``` + +- The first line must be exactly `v1` with no leading/trailing whitespace and no frontmatter + before it. +- If the existing `memory_summary.md` first line is not exactly `v1`, discard the old summary + structure and regenerate the entire file from the finalized `MEMORY.md`, skills, and current + rollout evidence. + +Density objective (strict): + +- `memory_summary.md` is prompt-loaded context, so optimize for high signal per token. +- Keep only high-level, cross-task signal and brief routing summaries. Put details, provenance, + runbooks, and task-local nuance in `MEMORY.md`, skills, or rollout summaries. +- Deduplicate aggressively. If two bullets would cause the same future behavior or route to the + same `MEMORY.md` area, merge them or keep the sharper one. +- Prefer short, concrete bullets over narrative explanation. Delete low-signal caveats, + examples, and historical detail unless they change future agent behavior. +- Give directly links to important information to maximize the retrieval efficiency. + Format: ## User Profile @@ -474,15 +505,15 @@ For example, include (when known): You may end with short fun facts if they are real and useful, but keep the main profile concrete and grounded. Do not let the optional fun-facts tail make the rest of the section more stylized or abstract. -This entire section is free-form, <= 500 words. +This entire section is free-form, <= 350 words. ## User preferences Include a dedicated bullet list of actionable user preferences that are likely to matter again, not just inside one task group. This section should be more concrete and easier to apply than `## User Profile`. Prefer preferences that repeatedly save user keystrokes or avoid predictable interruption. -This section may be long. Do not compress it to just a few umbrella bullets when `MEMORY.md` -contains many distinct actionable preferences. +Keep it dense and non-duplicative. Include only stable or high-leverage preferences that would +change future agent behavior across recurring workflows. Treat this as the main actionable payload of `memory_summary.md`. For example, include (when known): @@ -500,16 +531,12 @@ Rules: - Keep each bullet actionable and future-facing. - Default to lifting or lightly adapting strong bullets from `MEMORY.md` `## User preferences` rather than rewriting them into smoother higher-level summaries. -- Preserve more of the user's original point than a terse summary would. Prefer evidence-aware - bullets that still keep some original wording over abstract umbrella summaries. +- Preserve the user's original point when it is compact and behavior-changing; otherwise compress + to the shortest faithful wording. - When a short quoted or near-verbatim phrase makes the preference easier to recognize or grep for later, keep that phrase in the bullet instead of replacing it with an abstraction. -- Do not over-merge adjacent preferences. If several distinct preferences would change different - future defaults, keep them as separate bullets. -- Prefer many narrow actionable bullets over a few broad umbrella bullets. -- Prefer a broad actionable inventory over a short highly deduped list. -- Do not treat 5-10 bullets as an implicit target; long-lived memory sets may justify a much - longer list. +- Merge adjacent preferences unless they would change different future defaults. +- Prefer a compact set of sharp bullets over a broad inventory. - Do not require a preference to be broad across task families. If it is likely to matter again in a recurring workflow, it belongs here. - When deciding whether to include a preference, ask whether omitting it would make the next @@ -542,16 +569,20 @@ For example, include (when known): This is a compact index to help future agents quickly find details in `MEMORY.md`, `skills/`, and `rollout_summaries/`. -Treat it as a routing/index layer, not a mini-handbook: +Treat it as a dense routing/index layer, not a mini-handbook: - tell future agents what to search first, - preserve enough specificity to route into the right `MEMORY.md` block quickly. +- keep topic descriptions brief; delete stale, duplicated, or low-signal topics even if they + existed in the previous summary. Topic selection and quality rules: - Organize the index first by cwd / project scope, then by topic. - Split the index into a recent high-utility window and older topics. - Do not target a fixed topic count. Include informative topics and omit low-signal noise. +- Keep the index current. Feel free to restructure, rename, merge, or delete topics when the + current `MEMORY.md` organization or evidence has changed. - Prefer grouping by task family / workflow intent, not by incidental tool overlap alone. - Order topics by utility, using `updated_at` recency as a strong default proxy unless there is strong contrary evidence. @@ -588,7 +619,7 @@ Recent Active Memory Window behavior (scope-first, then day-ordered): - Within each scope, order day subsections by recency. - If a scope has only one meaningful recent day, include only that day for that scope. - For each recent-day subsection inside a scope, prioritize informative, likely-to-recur topics and make - those entries richer (better keywords, clearer descriptions, and useful recent learnings); + those entries denser (better keywords, brief descriptions, and useful recent learnings); do not spend much space on trivial tasks touched that day. - Preserve routing coverage for `MEMORY.md` in the overall index. If a scope/day includes less useful topics, include shorter/compact entries for routing rather than dropping them. @@ -596,16 +627,16 @@ Recent Active Memory Window behavior (scope-first, then day-ordered): appears; do not duplicate it under multiple day sections. - If a topic spans multiple scopes and retrieval would differ by scope, split it. Otherwise, place it under the dominant scope and mention the secondary scope in the description. -- Recent-day entries should be richer than older-topic entries: stronger keywords, clearer - descriptions, and concise recent learnings/change notes. +- Recent-day entries should be more informative than older-topic entries through stronger + keywords and concise recent learnings/change notes, not longer prose. - Group similar tasks/topics together when it improves routing clarity. - Do not over cluster topics together, especially when they contain distinct task intents. Recent-topic format: - : , , , ... - - desc: - - learnings: + - desc: + - learnings: ### @@ -643,10 +674,9 @@ Notes: caveats, and decision triggers; move cross-task, stable, broadly reusable user defaults to `## User preferences`. - Coverage guardrail: ensure every top-level `# Task Group` in `MEMORY.md` is represented by - at least one topic bullet in this index (either directly or via a clearly subsuming topic). -- Keep descriptions explicit: what is inside, when to use it, and what kind of - outcome/procedure depth is available (for example: runbook, diagnostics, reporting, recovery), - so a future agent can quickly choose which topic/keyword cluster to search first. + at least one topic bullet in this index (either directly or via a clearly subsuming compact topic). +- Keep descriptions explicit but short: enough for a future agent to choose the right + topic/keyword cluster, not enough to replace opening `MEMORY.md`. - `memory_summary.md` should not sound like a second-order executive summary. Prefer concrete, source-faithful wording over polished abstraction, especially in: - `## User preferences` @@ -731,6 +761,9 @@ WORKFLOW ============================================================ 1. Determine mode (INIT vs INCREMENTAL UPDATE) using artifact availability and current run context. + Independently check `memory_summary.md` first line: if it is not exactly `v1`, regenerate + `memory_summary.md` from scratch after the other artifacts are finalized, even when `MEMORY.md` + itself can be updated incrementally. 2. INIT phase behavior: - Read `raw_memories.md` first, then rollout summaries carefully. @@ -747,8 +780,8 @@ WORKFLOW conflicting task families until MEMORY blocks are richer and more useful than raw memories 3. INCREMENTAL UPDATE behavior: - - Read existing `MEMORY.md` and `memory_summary.md` first for continuity and to locate - existing references that may need surgical cleanup. + - Read existing `MEMORY.md` and, only when it starts with exactly `v1`, existing + `memory_summary.md` first for continuity and to locate references that may need surgical cleanup. - Use the injected git-style workspace changes as the first routing pass: - added/modified `raw_memories.md` and `rollout_summaries/*.md` = ingestion queue - deleted `rollout_summaries/*.md` and `extensions/*/resources/*.md` = forgetting / @@ -775,6 +808,8 @@ WORKFLOW - doing light clustering and merging if needed - refreshing `MEMORY.md` top-of-file ordering so recent high-utility task families stay easy to find - rebuilding the `memory_summary.md` recent active window (last 3 memory days) from current `updated_at` coverage + - freely restructuring `memory_summary.md` so it reflects the current memory set without + stale topics, duplicated preference bullets, or obsolete routing labels - updating existing skills or adding new skills only when there is clear new reusable procedure - updating `memory_summary.md` last to reflect the final state of the memory folder - Minimize churn in incremental mode: if an existing `MEMORY.md` block or `## What's in Memory` @@ -816,6 +851,9 @@ WORKFLOW 7. Final pass: - remove duplication in memory_summary, skills/, and MEMORY.md + - verify `memory_summary.md` still begins with exactly `v1` + - verify `memory_summary.md` is dense: brief high-level profile, compact actionable + preferences, compact general tips, and a routing index rather than a second handbook - remove stale or low-signal blocks that are less likely to be useful in the future - remove or rewrite blocks/task sections whose supporting rollout references point only to deleted inputs or missing rollout summary files