Skip to content

Commit 13d3553

Browse files
author
Garming
committed
fix(studio): judge migration batches on a dynamic-tool app-server turn
Evaluation judging no longer parses the last `codex exec` agent message: the runner writes a durable `judge/request.json` for the batch it is judging and Studio answers it with one Codex app-server turn that returns the verdict through the `reportEvaluation` dynamic tool. The runner keeps owning batching, the thread record, the batch cache and the report, and still validates the verdict before caching it, so a rejected batch falls back to `codex exec` inside the same batch budget. Setting `AGENTKIT_MIGRATION_JUDGE_APP_SERVER=0` pins that scripted judge for the whole run instead of writing requests. The request declares how long the runner will listen, and the turn is sized to answer inside that window, so a batch that is too slow comes back as an answered failure instead of a verdict nobody reads. Two defects came out of the first real runs and are fixed here: the turn is now bounded by wall-clock time instead of the app-server's inactivity window, and a transport failure is answered with an error envelope instead of leaving the runner waiting. The `codex exec` fallback also uses the budget left after the channel rather than the budget the batch started with. `run_tool_turn` carries the shared one-turn contract for both the route analysis and the judge, and the analysis now uses it too. Verified with a real migration-evaluation run (Dify workflow source, Dev Sandbox): the channel delivered the verdict in 285.9s, the runner accepted it without falling back, and the report matches the legacy shape field for field.
1 parent 846ac98 commit 13d3553

13 files changed

Lines changed: 2821 additions & 44 deletions

File tree

‎frontend/README.md‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -600,9 +600,22 @@ See [deployment and operation](service/studio_release_notifier/README.md).
600600
the framework, entry point, and open questions. Structured frameworks run the
601601
preinstalled `ak migrate`; Dify and Any projects run
602602
`ak migrate --execution in-place` with Codex in the same Session. Evaluation
603-
deploys a temporary Runtime, checkpoints per-case execution as JSONL, judges
604-
batches in one fresh resumable Codex thread, and always reconciles Runtime
605-
cleanup before completing or cancelling. Reports show 0–100 display scores,
603+
deploys a temporary Runtime, checkpoints per-case execution as JSONL, and
604+
judges batches in one fresh resumable Codex thread. Analysis and judging both
605+
deliver their contract through an app-server dynamic tool instead of parsing
606+
free text, and the analysis turn runs on a Studio background worker so a long
607+
analysis never waits inside the upload request. Each judged batch is a durable
608+
request the runner writes into the Session: Studio answers it with one
609+
app-server turn carrying the verdict, and the runner validates and caches that
610+
verdict before the next batch. A request stays on disk until it is answered, so
611+
a Studio restart replays the same batch; a request that cannot be answered
612+
falls back to `codex exec` inside the same batch budget. The request declares
613+
how long the runner will listen, and the turn is sized to answer inside that
614+
window, so even a judge batch that is too slow comes back as an answered
615+
failure rather than a timeout. Setting
616+
`AGENTKIT_MIGRATION_JUDGE_APP_SERVER=0` pins that scripted judge for the
617+
whole run instead of writing judge requests at all. Cleanup is always
618+
reconciled before completing or cancelling. Reports show 0–100 display scores,
606619
execution success, evidence coverage, N/A counts, low-scoring and failed
607620
cases, versions, evidence severity, and cleanup status without a pass/fail
608621
verdict. Each raw judge score is rounded half up to a 0–100 integer before

‎frontend/server/migration/app_server.py‎

Lines changed: 16 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,9 @@
2626
from collections.abc import Callable
2727
import os
2828

29-
from veadk.cli.codex_app_server import (
30-
CodexAppServerError,
31-
CodexAppServerSession,
32-
CodexDynamicToolResult,
33-
)
29+
from veadk.cli.codex_app_server import CodexDynamicToolResult
3430

31+
from .codex_tool_turn import ToolTurnUnavailable, run_tool_turn
3532
from .contracts import MigrationContractError, validate_analysis_result
3633

3734
ROUTE_TOOL_NAME = "reportRoute"
@@ -104,36 +101,22 @@ async def run_route_analysis(
104101
) -> dict[str, object] | None:
105102
"""Run one analysis turn and return the validated route contract, if any."""
106103
recorder = RouteRecorder(attempt=attempt, input_sha256=input_sha256)
107-
session = CodexAppServerSession(endpoint)
108-
session.cwd = cwd
109-
if model:
110-
session.model = model
111-
session.register_dynamic_tool(
112-
ROUTE_TOOL_NAME,
113-
ROUTE_TOOL_DESCRIPTION,
114-
schema,
115-
recorder.submit,
116-
)
117-
try:
118-
await session.connect()
119-
except CodexAppServerError as error:
120-
raise MigrationAnalysisUnavailable(str(error)) from error
121104
try:
122-
async for event in session.stream_turn(
123-
prompt,
105+
await run_tool_turn(
106+
endpoint=endpoint,
107+
prompt=prompt,
108+
cwd=cwd,
109+
tool_name=ROUTE_TOOL_NAME,
110+
tool_description=ROUTE_TOOL_DESCRIPTION,
111+
tool_schema=schema,
112+
handler=recorder.submit,
113+
has_result=lambda: recorder.result is not None,
114+
model=model,
124115
timeout_seconds=timeout_seconds,
125-
):
126-
if event_sink is not None:
127-
event_sink(event)
128-
if recorder.result is not None:
129-
# 结果已经到手:终止本轮,避免继续消耗 token 和沙箱时间。
130-
await session.interrupt()
131-
break
132-
except CodexAppServerError as error:
133-
if recorder.result is None:
134-
raise MigrationAnalysisUnavailable(str(error)) from error
135-
finally:
136-
await session.close()
116+
event_sink=event_sink,
117+
)
118+
except ToolTurnUnavailable as error:
119+
raise MigrationAnalysisUnavailable(str(error)) from error
137120
return recorder.result
138121

139122

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""One Codex app-server turn that reports its result through a dynamic tool.
16+
17+
Studio drives every structured Codex turn the same way: a dynamic tool registered on
18+
``thread/start`` carries the result as typed JSON-RPC arguments, a rejected call returns
19+
``success: false`` so the same turn can correct itself, and the turn ends as soon as the
20+
contract has been delivered. Callers own the contract: they pass the tool spec, the
21+
validator, and the check that says the result has arrived.
22+
"""
23+
24+
from __future__ import annotations
25+
26+
import asyncio
27+
from collections.abc import Callable
28+
29+
from veadk.cli.codex_app_server import (
30+
CodexAppServerError,
31+
CodexAppServerSession,
32+
CodexDynamicToolResult,
33+
)
34+
35+
ToolHandler = Callable[[dict[str, object]], CodexDynamicToolResult]
36+
37+
__all__ = [
38+
"ToolHandler",
39+
"ToolTurnDeadlineExceeded",
40+
"ToolTurnUnavailable",
41+
"run_tool_turn",
42+
]
43+
44+
45+
class ToolTurnUnavailable(RuntimeError):
46+
"""The Sandbox app-server could not start or finish the tool turn."""
47+
48+
49+
class ToolTurnDeadlineExceeded(ToolTurnUnavailable):
50+
"""The turn kept making progress but ran past the caller's wall-clock window.
51+
52+
Callers treat this differently from a transport failure: the app-server works,
53+
the batch is simply too slow for the window that was granted.
54+
"""
55+
56+
57+
async def run_tool_turn(
58+
*,
59+
endpoint: str,
60+
prompt: str,
61+
cwd: str,
62+
tool_name: str,
63+
tool_description: str,
64+
tool_schema: dict[str, object],
65+
handler: ToolHandler,
66+
has_result: Callable[[], bool],
67+
thread_id: str = "",
68+
model: str = "",
69+
timeout_seconds: float,
70+
event_sink: Callable[[object], None] | None = None,
71+
) -> str:
72+
"""Run one turn and return the thread id that carried it.
73+
74+
``thread_id`` resumes a durable thread instead of starting a new one; the
75+
app-server restores that thread's dynamic tools, so the same contract keeps
76+
arriving. ``timeout_seconds`` is a wall-clock budget, not just the app-server's
77+
inactivity window: callers that must answer inside a caller-owned window cannot
78+
rely on a turn that keeps making progress, so this interrupts it at the deadline.
79+
Any protocol or transport failure becomes ``ToolTurnUnavailable`` so the caller can
80+
fall back, unless the result already arrived.
81+
"""
82+
session = CodexAppServerSession(endpoint)
83+
session.cwd = cwd
84+
if model:
85+
session.model = model
86+
session.register_dynamic_tool(
87+
tool_name,
88+
tool_description,
89+
tool_schema,
90+
handler,
91+
)
92+
used_thread = thread_id
93+
try:
94+
try:
95+
if thread_id:
96+
await session.attach_thread(thread_id)
97+
else:
98+
await session.connect()
99+
except CodexAppServerError as error:
100+
raise ToolTurnUnavailable(str(error)) from error
101+
deadline = asyncio.get_running_loop().time() + timeout_seconds
102+
try:
103+
async for event in session.stream_turn(
104+
prompt,
105+
timeout_seconds=timeout_seconds,
106+
):
107+
if event_sink is not None:
108+
event_sink(event)
109+
if has_result():
110+
# 结果已经到手:终止本轮,避免继续消耗 token 和沙箱时间。
111+
await session.interrupt()
112+
break
113+
if asyncio.get_running_loop().time() >= deadline:
114+
# 回合一直在产生进度,但已经超出调用方的窗口:主动收尾。
115+
await session.interrupt()
116+
raise ToolTurnDeadlineExceeded("Codex 回合超出时间预算。")
117+
except TimeoutError as error:
118+
# app-server 客户端自己的空闲超时:同样是「没在窗口内交付」。
119+
if not has_result():
120+
raise ToolTurnDeadlineExceeded(
121+
str(error) or "Codex 回合超时。"
122+
) from error
123+
except CodexAppServerError as error:
124+
if not has_result():
125+
raise ToolTurnUnavailable(str(error)) from error
126+
used_thread = session.thread_id or thread_id
127+
finally:
128+
await session.close()
129+
return used_thread

0 commit comments

Comments
 (0)