Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion docs/content/docs/framework/agent/runtime.en.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ Either way, a path that came from the *model* is untrusted input: resolve it aga
| :--- | :--- | :--- |
| `reasoning_effort` | `"medium"` | Reasoning budget: `minimal`/`low`/`medium`/`high`/`xhigh`. Higher is slower and uses more tokens. |
| `personality` | `"pragmatic"` | Codex's own reply style: `none`/`friendly`/`pragmatic`. |
| `base_instructions` | `None` | Replaces Codex's built-in base system prompt, on both transports. `None` keeps the built-in prompt. See "Replacing the built-in system prompt" below. |
| `mcp_process_reuse` | `True` | Same-instance reuse for stable ADK/MCP tools on `direct` + `resume`, with fresh per-invocation executors and SDK thread/turn identity validation. Set `False` to disable MCP process reuse. |
| `model_transport` | `"auto"` | How Codex reaches the model. `direct`: Codex calls a Responses-capable backend itself and uses the agent's tools through a local MCP server the runtime runs for the turn, so Codex drives the tool loop. `shim`: an in-process Responses→Chat shim sits in between and executes the tools, for chat-only backends. `auto` picks `direct` for Volcengine Ark (`*.volces.com`), BytePlus (`*.bytepluses.com`) and `api.openai.com`, `shim` otherwise; an agent whose `model_extra_config` has a non-empty `extra_body` stays on `shim` under `auto`, because `direct` cannot forward a request body. With an explicit `direct`, `extra_body` is not forwarded and a warning is logged. |
| `turn_timeout_seconds` | `1800.0` | Upper bound for one Codex turn, in seconds; past it the turn is interrupted and a `TimeoutError` is raised. `None` means no bound. On the `direct` transport with `thread_mode="resume"`, invocations of one session queue on a per-session lock, so an unbounded turn would also block every later invocation of that session; the default is therefore 30 minutes. In the other modes invocations of one session may run concurrently. |
Expand Down Expand Up @@ -330,7 +331,7 @@ These runs use the real Codex binary and a scripted loopback model without provi

### How instructions reach Codex

Codex ships its own ~20KB system prompt, tuned for its own toolchain (`apply_patch`, `update_plan`, shell, AGENTS.md). VeADK deliberately does **not** override it via `base_instructions`: Codex *replaces* the built-in template outright when that field is set, which would delete all of that guidance (Codex's own docs call the equivalent config key strongly discouraged).
Codex ships its own ~20KB system prompt, tuned for its own toolchain (`apply_patch`, `update_plan`, shell, AGENTS.md). By default VeADK does **not** override it via `base_instructions` (you can opt in explicitly; see below): Codex *replaces* the built-in template outright when that field is set, which would delete all of that guidance (Codex's own docs call the equivalent config key strongly discouraged).

The agent's identity block and its `instruction` therefore travel over Codex's native `developer_instructions` channel instead. Codex renders that as its own `developer` message alongside AGENTS.md, skills and environment context — purely additive, never a replacement.

Expand All @@ -343,6 +344,19 @@ Two corrections ride along on the same channel, because Codex's preserved system

The `direct` transport removes `request_user_input` (and `web_search`, multi-agent and other unused tools) from the thread config altogether, so its note carries only the first point. You do not need to counter-instruct for any of this in your agent's `instruction`.

#### Replacing the built-in system prompt

An agent that needs none of the coding-assistant guidance in Codex's built-in prompt (a question-answering agent, for example) can replace it outright by setting `CodexRuntimeConfig.base_instructions`:

```python
CodexRuntimeConfig(base_instructions="You are a Volcengine product Q&A assistant...")
```

- It applies on both transports: the text goes through the Codex SDK's native `thread_start(base_instructions=...)` and becomes the Responses request's `instructions`.
- The agent's identity block and `instruction` still travel as `developer_instructions`, unchanged, and the tool-availability note is still appended.
- `personality` is rendered into the built-in template being replaced, so it no longer takes effect once `base_instructions` is set.
- A persistent thread (`thread_mode="resume"`) keeps the base instructions it started with; changing the text starts a new thread. Leaving it unset keeps thread resumption unchanged.

### Codex environment variables

<Callout type="warn">
Expand Down
16 changes: 15 additions & 1 deletion docs/content/docs/framework/agent/runtime.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ def load_orders(day: str) -> dict:
| :--- | :--- | :--- |
| `reasoning_effort` | `"medium"` | 推理强度,可选 `minimal`/`low`/`medium`/`high`/`xhigh`。越高越慢、消耗 token 越多。 |
| `personality` | `"pragmatic"` | Codex 自带的回复风格,可选 `none`/`friendly`/`pragmatic`。 |
| `base_instructions` | `None` | 替换 Codex 内置的基础系统提示词,两种传输都生效;`None` 保留内置提示词。见下文「替换内置系统提示词」。 |
| `mcp_process_reuse` | `True` | 默认开启 `direct` + `resume` 下稳定 ADK/MCP 工具的本实例进程复用;每轮重新绑定执行器并校验 SDK 的 thread/turn 身份。设置 `False` 关闭 MCP 进程复用。 |
| `model_transport` | `"auto"` | Codex 如何访问模型:`direct` 让 Codex 直接调用支持 Responses API 的后端,并通过 runtime 为本轮启动的本地 MCP server 使用 Agent 的工具,工具循环由 Codex 驱动;`shim` 经由进程内 Responses→Chat 转换层,由转换层执行工具,适用于只支持 chat 的后端;`auto` 对火山方舟(`*.volces.com`)、BytePlus(`*.bytepluses.com`)与 `api.openai.com` 选 `direct`,其余选 `shim`;但 Agent 的 `model_extra_config` 含非空 `extra_body` 时,`auto` 仍选 `shim`,因为 `direct` 无法转发请求体。显式指定 `direct` 时 `extra_body` 不会被转发,并记录一条警告。 |
| `turn_timeout_seconds` | `1800.0` | 单个 Codex 回合的时间上限(秒),到期后中断回合并抛出 `TimeoutError`;`None` 表示不限。`direct` 传输且 `thread_mode="resume"` 时,同一 Session 的调用在一把 Session 级锁上排队,不设上限的回合会同时阻塞该 Session 之后的所有调用,因此默认给出 30 分钟上限;其余模式同一 Session 的调用可以并发执行。 |
Expand Down Expand Up @@ -298,7 +299,7 @@ uv run --extra dev python scripts/benchmark_codex_tools.py --scenario parallel -

### 指令是怎么下发给 Codex 的

Codex 自带一份约 20KB、针对自身工具链(`apply_patch`、`update_plan`、shell、AGENTS.md)调优过的系统提示词。VeADK **不会**通过 `base_instructions` 覆盖它——Codex 在该字段被设置时会**整体替换**内置模板,这些指导会被全部删掉(Codex 官方文档也强烈不建议这么做)。
Codex 自带一份约 20KB、针对自身工具链(`apply_patch`、`update_plan`、shell、AGENTS.md)调优过的系统提示词。VeADK 默认**不会**通过 `base_instructions` 覆盖它(需要时可显式配置,见下文)——Codex 在该字段被设置时会**整体替换**内置模板,这些指导会被全部删掉(Codex 官方文档也强烈不建议这么做)。

因此,Agent 的身份块与 `instruction` 走的是 Codex 原生的 `developer_instructions` 通道:Codex 把它渲染成一条独立的 `developer` 消息,与 AGENTS.md、skills、环境上下文并列,是纯追加而非替换。

Expand All @@ -311,6 +312,19 @@ Codex 自带一份约 20KB、针对自身工具链(`apply_patch`、`update_pla

`direct` 传输在 thread 配置里直接移除了 `request_user_input`(以及 `web_search`、多 agent 等用不到的工具),因此说明里只保留第一条。这些你都不需要再在自己 Agent 的 `instruction` 里手写一遍。

#### 替换内置系统提示词

如果 Agent 用不到 Codex 内置提示词里的编程助手指导(例如问答类 Agent),可以显式设置 `CodexRuntimeConfig.base_instructions`,用自己的文本整体替换它:

```python
CodexRuntimeConfig(base_instructions="你是火山引擎产品问答助手……")
```

- 两种传输都生效:该文本经 Codex SDK 原生的 `thread_start(base_instructions=...)` 传入,成为 Responses 请求的 `instructions`。
- Agent 的身份块与 `instruction` 仍走 `developer_instructions`,不受影响;工具可用性说明照常追加。
- `personality` 渲染在被替换的内置模板里,设置 `base_instructions` 后不再生效。
- 持久化 thread(`thread_mode="resume"`)保留启动时的基础提示词;修改该文本会新开 thread。未设置时 thread 续接行为不变。

### Codex 环境变量

<Callout type="warn">
Expand Down
74 changes: 74 additions & 0 deletions tests/runtime/codex/test_codex_runtime_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,80 @@ async def fake_get_shim(api_base, api_key):
assert "opaque-turn-token" not in messages


@pytest.mark.asyncio
async def test_runtime_replaces_base_instructions_only_when_configured(
monkeypatch,
caplog: pytest.LogCaptureFixture,
) -> None:
from veadk.runtime.codex import runtime as runtime_module

shim = _FakeShim()

async def fake_get_shim(api_base, api_key):
return shim

monkeypatch.setattr(runtime_module, "get_shim", fake_get_shim)
monkeypatch.setattr(runtime_module, "AsyncCodex", _FakeAsyncCodex)
monkeypatch.setattr(
runtime_module, "sync_skills_to_codex_home", lambda *_, **__: None
)
_FakeAsyncCodex.calls = {}
runtime_logger = logging.getLogger("veadk.runtime.codex.runtime")
runtime_logger.addHandler(caplog.handler)
runtime_logger.setLevel(logging.DEBUG)

agent = _Agent()
agent.codex_runtime_config = CodexRuntimeConfig(
base_instructions="You answer product questions only."
)
ctx = _Context(
invocation_id="inv-base",
agent=agent,
branch=None,
isolation_scope=None,
user_content=types.Content(role="user", parts=[types.Part(text="hello")]),
session=Session(
id="session-base",
appName="app",
userId="user",
state={},
events=[],
),
)

try:
[event async for event in CodexRuntime().run_async(agent, ctx)]
finally:
runtime_logger.removeHandler(caplog.handler)

thread_start = _FakeAsyncCodex.calls["thread_start"]
# The configured text replaces Codex's built-in system prompt; the agent
# identity and instruction still ride the additive developer channel.
assert thread_start["base_instructions"] == "You answer product questions only."
assert thread_start["developer_instructions"] == (
"Your name is agent.\n\nSDK contract agent\n\nFollow the contract.\n\n"
+ _TOOL_AVAILABILITY_NOTE
)
messages = "\n".join(record.getMessage() for record in caplog.records)
assert "codex_base_instructions_replaced invocation_id=inv-base" in messages
assert "codex_base_instructions_preserved" not in messages


def test_thread_instruction_hash_changes_with_base_instructions_only_when_set():
from veadk.runtime.codex.runtime import _thread_instruction_hash
from veadk.runtime.codex.thread_store import instruction_hash

# Unset keeps the hash threads were stored with, so upgrading does not
# restart every persistent thread.
assert _thread_instruction_hash("dev", None) == instruction_hash("dev")
# A thread keeps the base instructions it was started with, so changing
# them has to start a new thread.
assert _thread_instruction_hash("dev", "base") != instruction_hash("dev")
assert _thread_instruction_hash("dev", "base") != _thread_instruction_hash(
"dev", "other base"
)


@pytest.mark.asyncio
async def test_runtime_unregisters_turn_when_sdk_start_fails(monkeypatch) -> None:
from veadk.runtime.codex import runtime as runtime_module
Expand Down
14 changes: 14 additions & 0 deletions veadk/runtime/codex/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,20 @@ class CodexRuntimeConfig(BaseModel):
)
reasoning_effort: Literal["minimal", "low", "medium", "high", "xhigh"] = "medium"
personality: Literal["none", "friendly", "pragmatic"] = "pragmatic"
base_instructions: str | None = Field(
default=None,
min_length=1,
description=(
"Replaces Codex's built-in base system prompt (about 21KB of "
"coding-assistant guidance on apply_patch, shell use and planning) "
"for threads this runtime starts, on both transports. None keeps "
"the built-in prompt. The agent identity and instruction are still "
"sent as developer instructions. `personality` is rendered into "
"the replaced template, so it no longer applies. A persistent "
"thread keeps the text it started with, so changing it starts a "
"new thread."
),
)
max_tool_iterations: int = Field(
default=32,
ge=1,
Expand Down
70 changes: 53 additions & 17 deletions veadk/runtime/codex/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -702,13 +702,14 @@ def _emit_telemetry_once(llm_response: "LlmResponse") -> None:
# Keep privileged instructions out of the user transcript. The SDK
# exposes a native developer-instruction channel for them.
#
# `base_instructions` is deliberately NOT used: Codex *replaces*
# its built-in system prompt when it is set (models-manager
# overwrites `instructions_template` and nulls
# `instructions_variables`; nothing concatenates), which would
# delete ~20KB of shipped guidance covering AGENTS.md, planning,
# `update_plan`, `apply_patch` and shell-tool usage. Codex's own
# docs call the equivalent config key strongly discouraged.
# The agent's own text never goes into `base_instructions`: Codex
# *replaces* its built-in system prompt when it is set
# (models-manager overwrites `instructions_template` and nulls
# `instructions_variables`; nothing concatenates), which deletes
# ~20KB of shipped guidance covering AGENTS.md, planning,
# `update_plan`, `apply_patch` and shell-tool usage. Only an
# explicit `CodexRuntimeConfig.base_instructions` replaces it, for
# agents that need none of that guidance.
# `developer_instructions` is purely additive - Codex renders it as
# its own `developer` message alongside AGENTS.md, skills and
# environment context - so the agent identity block is folded in
Expand Down Expand Up @@ -784,7 +785,9 @@ def _resumed_input(covered_invocation_id: str) -> list[object]:
await _await_setup(
lock_stack.enter_async_context(_SESSION_LOCKS.hold(pool_key))
)
thread_instruction_hash = instruction_hash(developer_instructions)
thread_instruction_hash = _thread_instruction_hash(
developer_instructions, runtime_config.base_instructions
)
# CodexConfig.env is copied into only this subprocess. Never mutate
# process-wide CODEX_HOME or credential variables. The route's own
# credentials go in *after* the masking, which would otherwise
Expand Down Expand Up @@ -932,15 +935,28 @@ def _resumed_input(covered_invocation_id: str) -> list[object]:
runtime_config.network_access,
len(tool_bundle.executors),
)
logger.info(
"codex_base_instructions_preserved invocation_id=%s "
"identity_chars=%d developer_chars=%d "
"detail=Codex keeps its built-in system prompt; the agent "
"identity and instruction are sent as developer instructions.",
ctx.invocation_id,
len(runtime_call.base_instructions or ""),
len(developer_instructions),
)
if runtime_config.base_instructions is None:
logger.info(
"codex_base_instructions_preserved invocation_id=%s "
"identity_chars=%d developer_chars=%d "
"detail=Codex keeps its built-in system prompt; the agent "
"identity and instruction are sent as developer instructions.",
ctx.invocation_id,
len(runtime_call.base_instructions or ""),
len(developer_instructions),
)
else:
logger.info(
"codex_base_instructions_replaced invocation_id=%s "
"base_chars=%d identity_chars=%d developer_chars=%d "
"detail=Codex's built-in system prompt is replaced by the "
"configured base instructions; the agent identity and "
"instruction are sent as developer instructions.",
ctx.invocation_id,
len(runtime_config.base_instructions),
len(runtime_call.base_instructions or ""),
len(developer_instructions),
)
_annotate_span(
call_llm_span,
{
Expand Down Expand Up @@ -1055,6 +1071,11 @@ def _resumed_input(covered_invocation_id: str) -> list[object]:
model=model,
model_provider=route.provider_id,
developer_instructions=developer_instructions or None,
**(
{"base_instructions": runtime_config.base_instructions}
if runtime_config.base_instructions is not None
else {}
),
cwd=workspace,
ephemeral=not persistent,
approval_mode=_approval_mode(runtime_config),
Expand Down Expand Up @@ -1848,6 +1869,21 @@ async def _resume_with_retry(
raise AssertionError("unreachable")


def _thread_instruction_hash(
developer_instructions: str, base_instructions: str | None
) -> str:
"""Identity of the instructions a persistent thread was started with.

A resumed thread keeps both its developer and base instructions, so a
change to either has to start a new thread. Without configured base
instructions the hash equals the developer-only hash stored threads were
recorded with, so enabling nothing restarts no thread.
"""
if base_instructions is None:
return instruction_hash(developer_instructions)
return instruction_hash(f"{base_instructions}\0{developer_instructions}")


def _thread_binding(
ctx: "InvocationContext", agent: "Agent"
) -> tuple[ThreadKey | None, CodexThreadStore | None]:
Expand Down