diff --git a/docs/content/docs/framework/agent/runtime.en.mdx b/docs/content/docs/framework/agent/runtime.en.mdx index ca7d37665..6a5ac2a95 100644 --- a/docs/content/docs/framework/agent/runtime.en.mdx +++ b/docs/content/docs/framework/agent/runtime.en.mdx @@ -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. | @@ -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. @@ -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 diff --git a/docs/content/docs/framework/agent/runtime.mdx b/docs/content/docs/framework/agent/runtime.mdx index f1021f515..be731721e 100644 --- a/docs/content/docs/framework/agent/runtime.mdx +++ b/docs/content/docs/framework/agent/runtime.mdx @@ -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 的调用可以并发执行。 | @@ -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、环境上下文并列,是纯追加而非替换。 @@ -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 环境变量 diff --git a/tests/runtime/codex/test_codex_runtime_sdk.py b/tests/runtime/codex/test_codex_runtime_sdk.py index b708211b0..48d2f31ba 100644 --- a/tests/runtime/codex/test_codex_runtime_sdk.py +++ b/tests/runtime/codex/test_codex_runtime_sdk.py @@ -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 diff --git a/veadk/runtime/codex/config.py b/veadk/runtime/codex/config.py index d50fcfcec..12ff32454 100644 --- a/veadk/runtime/codex/config.py +++ b/veadk/runtime/codex/config.py @@ -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, diff --git a/veadk/runtime/codex/runtime.py b/veadk/runtime/codex/runtime.py index fb1ab0462..06da4b385 100644 --- a/veadk/runtime/codex/runtime.py +++ b/veadk/runtime/codex/runtime.py @@ -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 @@ -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 @@ -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, { @@ -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), @@ -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]: