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
+3 -7
View File
@@ -70,13 +70,9 @@ Run Claude Code CLI as a LangBot Runner.
The runner exposes a real MCP tool named `ask_user_question` through the
run-scoped LangBot MCP bridge. Its standard Claude `tool_use` event is converted
to LangBot's provider-neutral `interaction.requested` contract. The CLI process
is stopped while the user is answering. A card submission starts a new
AgentRun, resumes the same Claude Code session, and sends the answer as the next
authoritative user turn. Claude CLI must complete an MCP call before persisting
a resumable session, so it cannot accept a second result for the old
`tool_use_id` after the process exits. No Python coroutine or Claude process is
kept waiting for the user.
to a single LangBot `request_interaction` call. The call waits for the user's
card submission and returns all form values to the MCP tool in the same AgentRun.
The CLI process remains active while the interaction is pending.
## Shared-worker candidate / daemon upgrade
@@ -21,7 +21,6 @@ from langbot_plugin.api.agent_tools.mcp_access import AgentRunMCPAccess
from langbot_plugin.api.agent_tools.mcp_config import AgentMCPServerConfig
from langbot_plugin.api.definition.components.runner.runner import Runner
from langbot_plugin.api.entities.builtin.runner import (
InteractionAction,
InteractionField,
InteractionOption,
InteractionRequest,
@@ -40,7 +39,6 @@ from pkg.runtime_support import local_workspace, terminate_process_group
from pkg.steering import run_with_steering
SESSION_STATE_KEY = "external.claude_code_session_id"
PENDING_INTERACTION_STATE_KEY = "external.claude_code_pending_interaction"
SUPPORTED_LOCATIONS = {"local", "remote-ssh", "daemon"}
@@ -353,7 +351,7 @@ def _interaction_from_claude_tool(
title=title,
description=(str(tool_input.get("description") or "").strip()[:10000] or None),
fields=fields,
actions=[InteractionAction(id="submit", label="Submit", style="primary")],
actions=[],
fallback_text=f"{title}: " + "; ".join(field.label for field in fields),
)
continuation = {
@@ -365,11 +363,6 @@ def _interaction_from_claude_tool(
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 _submission_payload(
submission: InteractionSubmission,
continuation: dict[str, typing.Any],
@@ -393,14 +386,6 @@ def _submission_payload(
}
def _interaction_resume_prompt(payload: dict[str, typing.Any]) -> str:
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)
)
class ClaudeQuestionOption(pydantic.BaseModel):
label: str = pydantic.Field(min_length=1, max_length=512)
description: str | None = pydantic.Field(default=None, max_length=2000)
@@ -455,27 +440,10 @@ class ClaudeCodeExternalTools(AgentRunExternalTools):
request, continuation = _interaction_from_claude_tool(
uuid.uuid4().hex, args.model_dump(mode="json", by_alias=True)
)
answers: list[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="claude_code.interaction_expired")
answers.append(
{
"id": field.id,
"question": next(
(item["question"] for item in continuation["questions"] if item["id"] == field.id), field.label
),
"answer": result.submission.values.get(field.id),
}
)
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="claude_code.interaction_expired")
answers = _submission_payload(result.submission, continuation)["answers"]
return {"status": "submitted", "answers": answers}
@@ -576,31 +544,9 @@ class NativeClaudeCodeRunner(Runner):
async def run(self, ctx: RunnerContext) -> typing.AsyncGenerator[RunnerResult, None]:
try:
config = self._validate_config(ctx)
submission = ctx.input.interaction
if submission is not None:
continuation = _pending_interaction(ctx)
if continuation is None:
raise NativeCliError(
"pending Claude interaction state was not found",
code="claude_code.interaction_not_found",
)
tool_use_id = str(continuation.get("tool_use_id") or "").strip()
if not tool_use_id:
raise NativeCliError(
"pending Claude interaction has no tool_use_id",
code="claude_code.interaction_invalid",
)
prompt = _interaction_resume_prompt(_submission_payload(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="claude_code.empty_input")
prompt = _input_text(ctx)
if not prompt:
raise NativeCliError("input text is required", code="claude_code.empty_input")
session_id, session_created = self._session_id(ctx, config)
if session_created:
yield RunnerResult.state_updated(ctx.run_id, SESSION_STATE_KEY, session_id, scope="conversation")
@@ -876,7 +822,6 @@ async def _run_cli_process_events(
deadline = time.monotonic() + timeout
sequence = 0
final_parts: list[str] = []
pending_interaction: tuple[InteractionRequest, dict[str, typing.Any]] | None = None
terminal_result_text = ""
stderr_task = asyncio.create_task(process.stderr.read())
try:
@@ -910,15 +855,16 @@ async def _run_cli_process_events(
"data": {"key": SESSION_STATE_KEY, "value": session_id, "scope": "conversation"},
}
question_tool = _claude_question_tool_use(parsed)
if question_tool is not None and pending_interaction is None:
tool_use_id, tool_input = question_tool
pending_interaction = _interaction_from_claude_tool(tool_use_id, tool_input)
continue
if question_tool is not None:
raise NativeCliError(
"Use the LangBot ask_user_question MCP tool for user input",
code="claude_code.interaction_invalid",
)
chunk = _event_text(parsed)
if parsed.get("type") == "result" and chunk:
terminal_result_text = chunk
continue
if chunk and pending_interaction is None:
if chunk:
final_parts.append(chunk)
if streaming:
sequence += 1
@@ -940,24 +886,6 @@ async def _run_cli_process_events(
raise NativeCliError(
stderr or f"Claude Code exited with status {returncode}", code="claude_code.process_failed"
)
if pending_interaction is not None:
request, continuation = pending_interaction
yield {
"type": "state.updated",
"data": {
"key": PENDING_INTERACTION_STATE_KEY,
"value": continuation,
"scope": "conversation",
},
}
yield {
"type": "action.requested",
"data": {
"action": "interaction.requested",
"payload": request.model_dump(mode="json"),
},
}
return
final_text = (terminal_result_text or "".join(final_parts)).strip()
if not final_text:
raise NativeCliError("Claude Code returned no assistant text", code="claude_code.empty_response")
@@ -70,13 +70,9 @@ Run Claude Code CLI as a LangBot Runner.
The runner exposes a real MCP tool named `ask_user_question` through the
run-scoped LangBot MCP bridge. Its standard Claude `tool_use` event is converted
to LangBot's provider-neutral `interaction.requested` contract. The CLI process
is stopped while the user is answering. A card submission starts a new
AgentRun, resumes the same Claude Code session, and sends the answer as the next
authoritative user turn. Claude CLI must complete an MCP call before persisting
a resumable session, so it cannot accept a second result for the old
`tool_use_id` after the process exits. No Python coroutine or Claude process is
kept waiting for the user.
to a single LangBot `request_interaction` call. The call waits for the user's
card submission and returns all form values to the MCP tool in the same AgentRun.
The CLI process remains active while the interaction is pending.
## Shared-worker candidate / daemon upgrade