refactor(agent): unify blocking interaction callbacks and adapters

This commit is contained in:
fdc310
2026-10-31 19:53:17 +08:00
parent 02162a5a1a
commit bca82cb633
65 changed files with 1442 additions and 4241 deletions
+27 -211
View File
@@ -40,7 +40,6 @@ from pkg.runtime_support import local_workspace, serialize_codex_session, termin
from pkg.steering import run_with_steering
SESSION_STATE_KEY = "external.codex_session_id"
PENDING_INTERACTION_STATE_KEY = "external.codex_pending_interaction"
SUPPORTED_LOCATIONS = {"local", "remote-ssh", "daemon"}
DEFAULT_TIMEOUT_SECONDS = 1800.0
CODEX_STDIO_LIMIT_BYTES = 16 * 1024 * 1024
@@ -523,7 +522,7 @@ def _interaction_from_questions(
title=request_title,
description=description.strip()[:10000] or None,
fields=fields,
actions=[InteractionAction(id="submit", label="Submit", style="primary")],
actions=[],
fallback_text=f"{request_title}: " + "; ".join(field.label for field in fields),
)
continuation = {
@@ -568,11 +567,6 @@ def _interaction_from_codex_request(
return request, continuation
def _pending_interaction(ctx: RunnerContext) -> dict[str, typing.Any] | None:
value = ctx.state.conversation.get(PENDING_INTERACTION_STATE_KEY)
return dict(value) if isinstance(value, dict) else None
def _approval_item(params: dict[str, typing.Any], items: dict[str, dict[str, typing.Any]]) -> dict[str, typing.Any]:
item_id = str(params.get("itemId") or params.get("callId") or "")
item = items.get(item_id)
@@ -695,61 +689,6 @@ def _interaction_from_codex_approval(
return request, continuation
def _interaction_resume_prompt(
submission: InteractionSubmission,
continuation: dict[str, typing.Any],
) -> str:
if submission.interaction_id != continuation.get("interaction_id"):
raise NativeCliError(
"interaction submission does not match the pending Codex request", code="codex.interaction_mismatch"
)
question_by_id = {
str(item.get("id")): str(item.get("question") or item.get("id"))
for item in continuation.get("questions", [])
if isinstance(item, dict) and item.get("id")
}
answers = [
{"id": field_id, "question": question_by_id.get(field_id, field_id), "answer": value}
for field_id, value in submission.values.items()
]
payload = {"action": submission.action_id, "answers": answers}
return (
"The user answered the previously paused ask_user_question call. "
"Treat these values as the authoritative response and continue the interrupted task.\n"
+ json.dumps(payload, ensure_ascii=False)
)
def _approval_resume(
submission: InteractionSubmission,
continuation: dict[str, typing.Any],
) -> tuple[str, dict[str, str] | None]:
if submission.interaction_id != continuation.get("interaction_id"):
raise NativeCliError(
"interaction submission does not match the pending Codex approval", code="codex.interaction_mismatch"
)
action_id = str(submission.action_id or "")
summary = str(continuation.get("approval_summary") or "")
if action_id == APPROVE_ONCE_ACTION:
category = str(continuation.get("approval_category") or "")
fingerprint = str(continuation.get("approval_fingerprint") or "")
if not category or not fingerprint:
raise NativeCliError("pending Codex approval has no operation fingerprint", code="codex.approval_invalid")
prompt = (
"The user approved exactly one retry of the previously paused Codex operation. "
"Retry that same operation now. This approval does not authorize any different command or file change.\n"
+ summary
)
return prompt, {"category": category, "fingerprint": fingerprint}
if action_id == REJECT_ACTION:
prompt = (
"The user rejected the previously paused Codex operation. Do not retry that exact operation. "
"Continue with a safe alternative or explain why the task cannot proceed.\n" + summary
)
return prompt, None
raise NativeCliError(f"unsupported Codex approval action: {action_id}", code="codex.approval_invalid")
class NativeCodexRunner(Runner):
def _validate_config(self, ctx: RunnerContext) -> dict[str, typing.Any]:
data = ctx.config or {}
@@ -810,8 +749,6 @@ class NativeCodexRunner(Runner):
stored = self._stored_session_id(ctx)
if not stored or not config["reuse_session"]:
return ""
if ctx.input.interaction is not None and _pending_interaction(ctx) is not None:
return stored
if config["location"] == "local":
env = {**os.environ, **config["env"]}
pending_calls = _pending_session_tool_calls(_shared_codex_home(env), stored)
@@ -847,53 +784,14 @@ class NativeCodexRunner(Runner):
async def run(self, ctx: RunnerContext) -> typing.AsyncGenerator[RunnerResult, None]:
try:
config = self._validate_config(ctx)
submission = ctx.input.interaction
approval_grant: dict[str, str] | None = None
if submission is not None:
continuation = _pending_interaction(ctx)
if continuation is None:
raise NativeCliError(
"pending Codex interaction state was not found", code="codex.interaction_not_found"
)
if continuation.get("kind") == "approval":
prompt, approval_grant = _approval_resume(submission, continuation)
else:
prompt = _interaction_resume_prompt(submission, continuation)
yield RunnerResult.state_updated(
ctx.run_id,
PENDING_INTERACTION_STATE_KEY,
None,
scope="conversation",
)
else:
prompt = _input_text(ctx)
if not prompt:
raise NativeCliError("input text is required", code="codex.empty_input")
approval_grant_holder = {"value": approval_grant}
prompt = _input_text(ctx)
if not prompt:
raise NativeCliError("input text is required", code="codex.empty_input")
def run_turn(turn_prompt: str, resume_session_id: str) -> typing.AsyncGenerator[RunnerResult, None]:
turn_approval_grant = approval_grant_holder["value"]
approval_grant_holder["value"] = None
if config["location"] == "daemon":
if turn_approval_grant is None:
return self._run_daemon(ctx, config, turn_prompt, resume_session_id)
return self._run_daemon(
ctx,
config,
turn_prompt,
resume_session_id,
approval_grant=turn_approval_grant,
)
if turn_approval_grant is None:
return self._run_local_or_ssh(ctx, config, turn_prompt, resume_session_id)
return self._run_local_or_ssh(
ctx,
config,
turn_prompt,
resume_session_id,
approval_grant=turn_approval_grant,
)
return self._run_daemon(ctx, config, turn_prompt, resume_session_id)
return self._run_local_or_ssh(ctx, config, turn_prompt, resume_session_id)
async for result in run_with_steering(
ctx,
@@ -1182,8 +1080,6 @@ class _CodexAppServerClient:
self.final_error = ""
self.seen_agent_message_item_ids: set[str] = set()
self.final_chunk_emitted = False
self.interaction_request: InteractionRequest | None = None
self.interaction_continuation: dict[str, typing.Any] | None = None
self.approval_grant = dict(approval_grant) if approval_grant else None
self.approval_policy = approval_policy
self.ctx = ctx
@@ -1198,25 +1094,10 @@ class _CodexAppServerClient:
if self.timeout_scope is not None:
self.timeout_scope.reschedule(loop.time() + 3600 + self.operation_timeout)
try:
if request.actions and not request.fields:
result = await self.ctx.request_interaction(request, timeout=3600, on_unsupported="degrade")
if result.status != "submitted" or result.submission is None:
raise NativeCliError(f"User interaction {result.status}", code="codex.interaction_expired")
return result.submission
values: dict[str, typing.Any] = {}
for field in request.fields:
step = request.model_copy(
update={
"interaction_id": f"{request.interaction_id}-{field.id}"[:255],
"fields": [field],
"actions": [],
}
)
result = await self.ctx.request_interaction(step, timeout=3600, on_unsupported="degrade")
if result.status != "submitted" or result.submission is None:
raise NativeCliError(f"User interaction {result.status}", code="codex.interaction_expired")
values[field.id] = result.submission.values.get(field.id)
return InteractionSubmission(interaction_id=request.interaction_id, values=values)
result = await self.ctx.request_interaction(request, timeout=3600, on_unsupported="degrade")
if result.status != "submitted" or result.submission is None:
raise NativeCliError(f"User interaction {result.status}", code="codex.interaction_expired")
return result.submission
finally:
if self.timeout_scope is not None:
self.timeout_scope.reschedule(loop.time() + self.operation_timeout)
@@ -1399,18 +1280,11 @@ class _CodexAppServerClient:
self.approval_grant = None
await self.respond(request_id, {"decision": "accept"})
return
request, continuation = _interaction_from_codex_approval(method, params, item)
if self.ctx is not None:
submission = await self._wait_for_user(request)
await self.respond(
request_id, {"decision": "accept" if submission.action_id == APPROVE_ONCE_ACTION else "cancel"}
)
return
self.interaction_request = request
self.interaction_continuation = continuation
await self.respond(request_id, {"decision": "cancel"})
await self._interrupt_turn(params)
self.finish_turn()
request, _ = _interaction_from_codex_approval(method, params, item)
submission = await self._wait_for_user(request)
await self.respond(
request_id, {"decision": "accept" if submission.action_id == APPROVE_ONCE_ACTION else "cancel"}
)
elif method in {"item/tool/call", "item/tool/requestUserInput"}:
params = raw.get("params")
if not isinstance(params, dict):
@@ -1419,73 +1293,36 @@ class _CodexAppServerClient:
await self.respond_error(request_id, f"unhandled dynamic tool: {params.get('tool')}")
return
try:
request, continuation = _interaction_from_codex_request(method, params)
request, _ = _interaction_from_codex_request(method, params)
except NativeCliError as exc:
await self.respond_error(request_id, exc.message)
return
if self.ctx is not None:
submission = await self._wait_for_user(request)
if method == "item/tool/call":
await self.respond(
request_id,
{
"success": True,
"contentItems": [
{"type": "inputText", "text": json.dumps(submission.values, ensure_ascii=False)}
],
},
)
else:
await self.respond(
request_id,
{
"answers": {
field_id: {"answers": value if isinstance(value, list) else [str(value)]}
for field_id, value in submission.values.items()
}
},
)
return
self.interaction_request = request
self.interaction_continuation = continuation
submission = await self._wait_for_user(request)
if method == "item/tool/call":
await self.respond(
request_id,
{
"success": True,
"contentItems": [
{
"type": "inputText",
"text": "LangBot paused this run for user input; the answer will arrive in a later turn.",
}
{"type": "inputText", "text": json.dumps(submission.values, ensure_ascii=False)}
],
},
)
else:
empty_answers = {field.id: {"answers": []} for field in request.fields}
await self.respond(request_id, {"answers": empty_answers})
await self._interrupt_turn(params)
self.finish_turn()
await self.respond(
request_id,
{
"answers": {
field_id: {"answers": value if isinstance(value, list) else [str(value)]}
for field_id, value in submission.values.items()
}
},
)
elif method == "mcpServer/elicitation/request":
await self.respond(request_id, {"action": "accept", "content": None, "_meta": None})
else:
await self.respond_error(request_id, f"unhandled server request: {method}")
async def _interrupt_turn(self, params: dict[str, typing.Any]) -> None:
thread_id = str(params.get("threadId") or self.thread_id)
turn_id = str(params.get("turnId") or "")
if not thread_id or not turn_id:
return
self.next_id += 1
await self._write_json(
{
"jsonrpc": "2.0",
"id": self.next_id,
"method": "turn/interrupt",
"params": {"threadId": thread_id, "turnId": turn_id},
}
)
async def handle_notification(self, raw: dict[str, typing.Any]) -> None:
method = str(raw.get("method") or "")
params = raw.get("params")
@@ -1701,27 +1538,6 @@ async def _run_cli_process_events(
raise NativeCliError(
stderr or f"Codex app-server exited with status {process.returncode}", code="codex.process_failed"
)
if client.interaction_request is not None and client.interaction_continuation is not None:
result_sequence += 1
yield {
"type": "state.updated",
"sequence": result_sequence,
"data": {
"key": PENDING_INTERACTION_STATE_KEY,
"value": client.interaction_continuation,
"scope": "conversation",
},
}
result_sequence += 1
yield {
"type": "action.requested",
"sequence": result_sequence,
"data": {
"action": "interaction.requested",
"payload": client.interaction_request.model_dump(mode="json"),
},
}
return
if client.final_error:
raise NativeCliError(client.final_error, code="codex.turn_failed")
final_text = "".join(client.final_parts).strip()