mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-11-02 05:45:20 +08:00
refactor(agent): unify blocking interaction callbacks and adapters
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user