feat(interactions): resume runner in place after card submission

This commit is contained in:
fdc310
2026-10-31 03:17:42 +08:00
parent 72068e41fa
commit bafe1df93d
29 changed files with 1223 additions and 195 deletions
@@ -238,7 +238,9 @@ _LANGBOT_MCP_SYSTEM_PROMPT = (
"any mcp__langbot_agent__* tool, then call that tool directly. In coordinator mode, "
"the main session may expose only delegation tools; delegate the MCP request to one "
"worker and require that worker to perform the same ToolSearch-then-call flow. Never "
"claim a LangBot tool result unless the direct or delegated tool call completed."
"claim a LangBot tool result unless the direct or delegated tool call completed. "
"For user questions, call mcp__langbot_agent__ask_user_question so the answer returns "
"inside the current CLI process; do not call the built-in AskUserQuestion tool."
)
@@ -450,7 +452,25 @@ class ClaudeCodeExternalTools(AgentRunExternalTools):
args_model=AskUserQuestionArgs,
)
async def ask_user_question(self, args: AskUserQuestionArgs) -> dict[str, typing.Any]:
return {"status": "paused", "question_count": len(args.questions)}
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),
})
return {'status': 'submitted', 'answers': answers}
class NativeClaudeCodeRunner(Runner):
+60 -1
View File
@@ -1112,6 +1112,7 @@ async def _run_cli_process(
_run_cli_process_events(
command,
args,
ctx=ctx,
cwd=cwd,
env=env,
timeout=timeout,
@@ -1165,6 +1166,8 @@ class _CodexAppServerClient:
streaming: bool,
approval_grant: dict[str, str] | None,
approval_policy: str | None,
ctx: RunnerContext | None = None,
operation_timeout: float = 120,
) -> None:
self.process = process
self.streaming = streaming
@@ -1183,8 +1186,39 @@ class _CodexAppServerClient:
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
self.operation_timeout = operation_timeout
self.timeout_scope: asyncio.Timeout | None = None
self.items: dict[str, dict[str, typing.Any]] = {}
async def _wait_for_user(self, request: InteractionRequest) -> InteractionSubmission:
if self.ctx is None:
raise NativeCliError('No active run for interaction', code='codex.interaction_invalid')
loop = asyncio.get_running_loop()
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)
finally:
if self.timeout_scope is not None:
self.timeout_scope.reschedule(loop.time() + self.operation_timeout)
async def _write_json(self, payload: dict[str, typing.Any]) -> None:
assert self.process.stdin is not None
data = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + b"\n"
@@ -1364,6 +1398,12 @@ class _CodexAppServerClient:
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"})
@@ -1381,6 +1421,21 @@ class _CodexAppServerClient:
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
if method == "item/tool/call":
@@ -1560,6 +1615,7 @@ async def _run_cli_process_events(
command: str,
args: list[str],
*,
ctx: RunnerContext | None = None,
cwd: str | None,
env: dict[str, str],
timeout: float,
@@ -1598,6 +1654,8 @@ async def _run_cli_process_events(
streaming=streaming,
approval_grant=approval_grant,
approval_policy=approval_policy,
ctx=ctx,
operation_timeout=timeout,
)
reader_task = asyncio.create_task(client.read_stdout())
stderr_task = asyncio.create_task(process.stderr.read())
@@ -1606,7 +1664,8 @@ async def _run_cli_process_events(
if initial_stdin:
process.stdin.write(initial_stdin)
await process.stdin.drain()
async with asyncio.timeout(timeout):
async with asyncio.timeout(timeout) as timeout_scope:
client.timeout_scope = timeout_scope
await client.initialize()
thread_id = await client.start_or_resume_thread(
resume_session_id,
+5 -6
View File
@@ -56,8 +56,8 @@ events, which are used only when no explicit answer was produced.
When Dify emits `workflow_paused` with a `human_input_required` reason, or a
direct `human_input_required` event, the runner asks LangBot to deliver a
structured interaction. A validated `interaction.submitted` event resumes Dify
through `/form/human_input/{form_token}`. The submission response may itself
structured interaction and waits for the validated submission in the same run.
It then resumes Dify through `/form/human_input/{form_token}`. The response may itself
stream events; otherwise the runner reads `/workflow/{workflow_run_id}/events`.
Workflows that pause again produce a new interaction and can be resumed again.
Forms are emitted as atomic steps: the runner collects one field at a time and
@@ -65,10 +65,9 @@ then emits the action buttons. This matches LangBot delivery adapters that
support a single select or an action-only confirmation.
Provider continuation data is not included in the interaction payload. The
runner stores `form_token`, `workflow_run_id`, the Dify user tag, and field/action
mappings in authorized plugin storage under a random LangBot `interaction_id`.
The continuation is deleted after a successful terminal event or after it is
replaced by a later pause.
runner keeps `form_token`, `workflow_run_id`, the Dify user tag, and field/action
mappings in the active run. Previously issued interactions can still resume
through their stored continuation records during migration.
The selected Runner binding must allow interactions, and the delivery
adapter must support the requested field/action shape. Adapters with a smaller
@@ -907,6 +907,45 @@ class DefaultRunner(Runner):
await self._store_interaction_continuation(ctx, continuation)
return RunnerResult.interaction_requested(ctx.run_id, request)
async def _wait_and_resume_workflow(
self, ctx: RunnerContext, client: AsyncDifyClient,
event: dict[str, typing.Any], user: str, remove_think: bool,
) -> typing.AsyncGenerator[RunnerResult, None]:
reason = _human_input_reason(event)
if reason is None:
raise DifyAPIError('Dify paused without a supported human-input reason', code='dify.response_invalid')
_, continuation = self._build_interaction_request(reason, _workflow_run_id(event), user)
while continuation['phase'] == 'field':
request = self._request_from_continuation(continuation)
result = await ctx.request_interaction(request, timeout=3600, on_unsupported='degrade')
if result.status != 'submitted' or result.submission is None:
raise DifyAPIError(f'Dify human input {result.status}', code='dify.interaction_expired')
field_map = continuation['field_map']
inputs = continuation['default_inputs']
for field_id, value in result.submission.values.items():
if field_id in field_map:
inputs[field_map[field_id]] = value
current_field = InteractionField.model_validate(continuation['current_fields'][0])
provider_name = field_map.get(current_field.id)
if current_field.required and provider_name and inputs.get(provider_name) in (None, '', []):
raise DifyAPIError(f'Dify human-input field is required: {current_field.label}', code='dify.interaction_invalid')
remaining = continuation['remaining_fields']
continuation['interaction_id'] = f'dify-{uuid.uuid4().hex}'
if remaining:
continuation['current_fields'] = [remaining.pop(0)]
else:
continuation['phase'] = 'action'
continuation['current_fields'] = []
request = self._request_from_continuation(continuation)
result = await ctx.request_interaction(request, timeout=3600, on_unsupported='degrade')
if result.status != 'submitted' or result.submission is None:
raise DifyAPIError(f'Dify human input {result.status}', code='dify.interaction_expired')
async for item in self._resume_workflow_once(
ctx, client, result.submission, remove_think,
continuation=continuation, blocking=True,
):
yield item
async def run(self, ctx: RunnerContext) -> typing.AsyncGenerator[RunnerResult, None]:
"""Run the Dify agent.
@@ -1079,13 +1118,8 @@ class DefaultRunner(Runner):
final_conversation_id,
scope="conversation",
)
interaction_result = await self._interaction_result_for_pause(ctx, event, user)
if interaction_result is None:
raise DifyAPIError(
"Dify paused the workflow without a supported reason",
code="dify.response_invalid",
)
yield interaction_result
async for result in self._wait_and_resume_workflow(ctx, client, event, user, remove_think):
yield result
return
elif event_type == "message" or event_type == "agent_message":
@@ -1342,13 +1376,8 @@ class DefaultRunner(Runner):
external_conv_id,
scope="conversation",
)
interaction_result = await self._interaction_result_for_pause(ctx, event, user)
if interaction_result is None:
raise DifyAPIError(
"Dify paused the workflow without a supported reason",
code="dify.response_invalid",
)
yield interaction_result
async for result in self._wait_and_resume_workflow(ctx, client, event, user, remove_think):
yield result
return
elif event_type == "workflow_finished":
@@ -1426,9 +1455,11 @@ class DefaultRunner(Runner):
client: AsyncDifyClient,
submission: typing.Any,
remove_think: bool,
continuation: dict[str, typing.Any] | None = None,
blocking: bool = False,
) -> typing.AsyncGenerator[RunnerResult, None]:
interaction_id = str(submission.interaction_id)
continuation = await self._load_interaction_continuation(ctx, interaction_id)
continuation = continuation if continuation is not None else await self._load_interaction_continuation(ctx, interaction_id)
if continuation.get("phase") == "field":
yield await self._advance_field_interaction(ctx, continuation, submission)
return
@@ -1449,7 +1480,8 @@ class DefaultRunner(Runner):
# Persist consumption BEFORE the external effect. An ambiguous failure
# remains consumed, never silently retries an approval at the vendor.
continuation["consumed"] = True
await self._store_interaction_continuation(ctx, continuation)
if not blocking:
await self._store_interaction_continuation(ctx, continuation)
pending_content = ""
streamed_content = ""
@@ -1526,18 +1558,22 @@ class DefaultRunner(Runner):
**data,
"workflow_run_id": str(continuation["workflow_run_id"]),
}
interaction_result = await self._interaction_result_for_pause(
ctx,
event,
str(continuation["user"]),
)
if interaction_result is None:
raise DifyAPIError(
"Dify paused the workflow without a supported reason",
code="dify.response_invalid",
if blocking:
async for result in self._wait_and_resume_workflow(
ctx, client, event, str(continuation['user']), remove_think,
):
yield result
else:
interaction_result = await self._interaction_result_for_pause(
ctx, event, str(continuation['user']),
)
await self._delete_interaction_continuation(ctx, interaction_id)
yield interaction_result
if interaction_result is None:
raise DifyAPIError(
'Dify paused the workflow without a supported reason',
code='dify.response_invalid',
)
await self._delete_interaction_continuation(ctx, interaction_id)
yield interaction_result
return
elif event_type == "workflow_finished":
data = event.get("data", {})
@@ -1574,5 +1610,6 @@ class DefaultRunner(Runner):
"Dify workflow returned no response after human input",
code="dify.api_error",
)
await self._delete_interaction_continuation(ctx, interaction_id)
if not blocking:
await self._delete_interaction_continuation(ctx, interaction_id)
yield RunnerResult.run_completed(ctx.run_id, usage=usage)
@@ -38,8 +38,8 @@ Configuration is **static** and should not contain runtime state. Only the follo
## Workflow Human Input
When Dify emits `workflow_paused` with a `human_input_required` reason, the
runner asks LangBot to deliver a structured interaction. A validated
`interaction.submitted` event resumes Dify through
runner asks LangBot to deliver a structured interaction and waits for the
validated submission in the same run. It resumes Dify through
`/form/human_input/{form_token}` and `/workflow/{workflow_run_id}/events`.
Workflows that pause again produce a new interaction and can be resumed again.
Forms are emitted as atomic steps: the runner collects one field at a time and
@@ -47,10 +47,9 @@ then emits the action buttons. This matches LangBot delivery adapters that
support a single select or an action-only confirmation.
Provider continuation data is not included in the interaction payload. The
runner stores `form_token`, `workflow_run_id`, the Dify user tag, and field/action
mappings in authorized plugin storage under a random LangBot `interaction_id`.
The continuation is deleted after a successful terminal event or after it is
replaced by a later pause.
runner keeps `form_token`, `workflow_run_id`, the Dify user tag, and field/action
mappings in the active run. Previously issued interactions can still resume
through their stored continuation records during migration.
The selected Runner binding must allow interactions, and the delivery
adapter must support the requested field/action shape. Adapters with a smaller
+40 -87
View File
@@ -18,6 +18,7 @@ from langbot_plugin.api.entities.builtin.runner import (
AgentTrigger,
DeliveryContext,
InteractionSubmission,
InteractionResult,
RunnerContext,
)
@@ -852,12 +853,20 @@ def _write_fake_codex_app_server(path: Path) -> None:
" method = request.get('method')",
" request_id = request.get('id')",
" params = request.get('params') or {}",
" if request_id == 700 and method is None:",
" answer = (request.get('result') or {}).get('contentItems') or []",
" send({'jsonrpc': '2.0', 'method': 'item/completed', 'params': {'threadId': thread_id, 'item': {'id': 'answer-result', 'type': 'agentMessage', 'text': 'USER_ANSWER:' + str(answer), 'phase': 'final_answer'}}})",
" send({'jsonrpc': '2.0', 'method': 'turn/completed', 'params': {'threadId': thread_id, 'turn': {'id': 'turn-1', 'status': 'completed'}}})",
" continue",
" if request_id == 701 and method is None and pending_approval_prompt:",
" decision = (request.get('result') or {}).get('decision')",
" if decision == 'accept':",
" send({'jsonrpc': '2.0', 'method': 'item/completed', 'params': {'threadId': thread_id, 'item': {'id': 'command-1', 'type': 'commandExecution', 'command': 'Write approval-probe.txt', 'cwd': '.', 'commandActions': [], 'status': 'completed', 'aggregatedOutput': 'created'}}})",
" send({'jsonrpc': '2.0', 'method': 'item/completed', 'params': {'threadId': thread_id, 'item': {'id': 'approval-result', 'type': 'agentMessage', 'text': 'APPROVAL_EXECUTED', 'phase': 'final_answer'}}})",
" send({'jsonrpc': '2.0', 'method': 'turn/completed', 'params': {'threadId': thread_id, 'turn': {'id': 'turn-approval', 'status': 'completed'}}})",
" else:",
" send({'jsonrpc': '2.0', 'method': 'item/completed', 'params': {'threadId': thread_id, 'item': {'id': 'rejection-result', 'type': 'agentMessage', 'text': 'APPROVAL_REJECTED', 'phase': 'final_answer'}}})",
" send({'jsonrpc': '2.0', 'method': 'turn/completed', 'params': {'threadId': thread_id, 'turn': {'id': 'turn-approval', 'status': 'completed'}}})",
" pending_approval_prompt = ''",
" continue",
" if request_id is not None and method == 'initialize':",
@@ -1238,11 +1247,27 @@ def test_codex_runner_defaults_to_non_interactive_full_access(tmp_path: Path) ->
assert config["sandbox_mode"] == "danger-full-access"
def test_codex_runner_pauses_for_dynamic_question_and_resumes_thread(tmp_path: Path) -> None:
def _interaction_context(config, text, *, action_id=None, values=None):
ctx = _native_ctx(config, text=text)
async def request_interaction(request, **kwargs):
return InteractionResult(
status='submitted',
submission=InteractionSubmission(
interaction_id=request.interaction_id,
action_id=action_id,
values=values or {},
),
)
ctx._api = types.SimpleNamespace(request_interaction=request_interaction)
return ctx
def test_codex_runner_answers_dynamic_question_in_live_turn(tmp_path: Path) -> None:
fake_cli = tmp_path / "fake_codex_app_server.py"
_write_fake_codex_app_server(fake_cli)
module = _load_runner_module("codex-agent")
native = sys.modules["pkg.native_cli"]
runner = object.__new__(module.DefaultRunner)
runner.get_plugin_config = lambda: {}
runner.get_run_api = lambda ctx: None
@@ -1255,37 +1280,12 @@ def test_codex_runner_pauses_for_dynamic_question_and_resumes_thread(tmp_path: P
"sandbox-mode": "read-only",
}
paused = asyncio.run(_collect_async(runner.run(_native_ctx(config, text="ASK_INTERACTION"))))
resumed = asyncio.run(_collect_async(runner.run(_interaction_context(
config, 'ASK_INTERACTION', values={'environment': 'production'},
))))
assert [item.type for item in paused] == ["state.updated", "state.updated", "action.requested"]
pending = paused[1].data["value"]
request = paused[2].data["payload"]
assert paused[2].data["action"] == "interaction.requested"
assert request["fields"][0]["type"] == "select"
assert pending["provider_call_id"] == "call-question-1"
resumed = asyncio.run(
_collect_async(
runner.run(
_native_ctx(
config,
conversation_state={
native.SESSION_STATE_KEY: "thread-123",
native.PENDING_INTERACTION_STATE_KEY: pending,
},
interaction=InteractionSubmission(
interaction_id=request["interaction_id"],
action_id="submit",
values={"environment": "production"},
),
)
)
)
)
assert resumed[0].data["key"] == native.PENDING_INTERACTION_STATE_KEY
assert resumed[0].data["value"] is None
assert resumed[-1].type == "run.completed"
assert not any(item.type == 'action.requested' for item in resumed)
assert any(
"production" in str(item.data.get("chunk", {}).get("content") or "")
for item in resumed
@@ -1293,11 +1293,10 @@ def test_codex_runner_pauses_for_dynamic_question_and_resumes_thread(tmp_path: P
)
def test_codex_runner_pauses_for_native_approval_and_approves_exact_retry(tmp_path: Path) -> None:
def test_codex_runner_approves_native_request_in_live_turn(tmp_path: Path) -> None:
fake_cli = tmp_path / "fake_codex_app_server.py"
_write_fake_codex_app_server(fake_cli)
module = _load_runner_module("codex-agent")
native = sys.modules["pkg.native_cli"]
runner = object.__new__(module.DefaultRunner)
runner.get_plugin_config = lambda: {}
runner.get_run_api = lambda ctx: None
@@ -1310,39 +1309,12 @@ def test_codex_runner_pauses_for_native_approval_and_approves_exact_retry(tmp_pa
"sandbox-mode": "read-only",
}
paused = asyncio.run(_collect_async(runner.run(_native_ctx(config, text="TRIGGER_COMMAND_APPROVAL"))))
resumed = asyncio.run(_collect_async(runner.run(_interaction_context(
config, 'TRIGGER_COMMAND_APPROVAL', action_id='approve_once',
))))
assert [item.type for item in paused] == ["state.updated", "state.updated", "action.requested"]
pending = paused[1].data["value"]
request = paused[2].data["payload"]
assert pending["kind"] == "approval"
assert pending["approval_category"] == "command"
assert request["kind"] == "confirmation"
assert request["fields"] == []
assert [action["id"] for action in request["actions"]] == ["approve_once", "reject"]
assert "Write approval-probe.txt" in request["description"]
resumed = asyncio.run(
_collect_async(
runner.run(
_native_ctx(
config,
conversation_state={
native.SESSION_STATE_KEY: "thread-123",
native.PENDING_INTERACTION_STATE_KEY: pending,
},
interaction=InteractionSubmission(
interaction_id=request["interaction_id"],
action_id="approve_once",
),
)
)
)
)
assert resumed[0].data["key"] == native.PENDING_INTERACTION_STATE_KEY
assert resumed[0].data["value"] is None
assert resumed[-1].type == "run.completed"
assert not any(item.type == 'action.requested' for item in resumed)
assert any(
"APPROVAL_EXECUTED" in str(item.data.get("chunk", {}).get("content") or "")
for item in resumed
@@ -1350,11 +1322,10 @@ def test_codex_runner_pauses_for_native_approval_and_approves_exact_retry(tmp_pa
)
def test_codex_runner_resumes_after_native_approval_rejection(tmp_path: Path) -> None:
def test_codex_runner_rejects_native_request_in_live_turn(tmp_path: Path) -> None:
fake_cli = tmp_path / "fake_codex_app_server.py"
_write_fake_codex_app_server(fake_cli)
module = _load_runner_module("codex-agent")
native = sys.modules["pkg.native_cli"]
runner = object.__new__(module.DefaultRunner)
runner.get_plugin_config = lambda: {}
runner.get_run_api = lambda ctx: None
@@ -1366,27 +1337,9 @@ def test_codex_runner_resumes_after_native_approval_rejection(tmp_path: Path) ->
"approval-policy": "on-request",
"sandbox-mode": "read-only",
}
paused = asyncio.run(_collect_async(runner.run(_native_ctx(config, text="TRIGGER_COMMAND_APPROVAL"))))
pending = paused[1].data["value"]
request = paused[2].data["payload"]
resumed = asyncio.run(
_collect_async(
runner.run(
_native_ctx(
config,
conversation_state={
native.SESSION_STATE_KEY: "thread-123",
native.PENDING_INTERACTION_STATE_KEY: pending,
},
interaction=InteractionSubmission(
interaction_id=request["interaction_id"],
action_id="reject",
),
)
)
)
)
resumed = asyncio.run(_collect_async(runner.run(_interaction_context(
config, 'TRIGGER_COMMAND_APPROVAL', action_id='reject',
))))
assert resumed[-1].type == "run.completed"
assert any(
@@ -22,6 +22,7 @@ from langbot_plugin.api.entities.builtin.runner import (
ConversationContext,
DeliveryContext,
InteractionSubmission,
InteractionResult,
RunnerContext,
)
@@ -92,6 +93,25 @@ def _ctx(
)
def _dify_interaction_ctx():
ctx = _ctx()
requests = []
async def request_interaction(request, **kwargs):
requests.append(request)
return InteractionResult(
status='submitted',
submission=InteractionSubmission(
interaction_id=request.interaction_id,
action_id=request.actions[0].id if request.actions else None,
values={request.fields[0].id: 'high'} if request.fields else {},
),
)
ctx._api = types.SimpleNamespace(request_interaction=request_interaction)
return ctx, requests
def _text_and_image_ctx(config: dict[str, Any]) -> RunnerContext:
return _ctx(
config=config,
@@ -231,25 +251,25 @@ def test_dify_workflow_pause_requests_host_interaction_and_hides_provider_tokens
},
}
results = asyncio.run(_collect_async(runner._run_workflow(_ctx(), FakeClient(), {}, "hello", "user_1", [], False)))
async def workflow_submit(self, **kwargs):
assert kwargs['form_token'] == 'form-private'
assert kwargs['workflow_run_id'] == 'workflow-private'
assert kwargs['inputs']['priority'] == 'high'
assert kwargs['action'] == 'approve-private'
yield {'event': 'node_finished', 'data': {'node_type': 'answer', 'outputs': {'answer': 'Approved'}}}
yield {'event': 'workflow_finished', 'data': {'outputs': {}}}
ctx, requests = _dify_interaction_ctx()
results = asyncio.run(_collect_async(runner._run_workflow(ctx, FakeClient(), {}, "hello", "user_1", [], False)))
interaction_result = results[-1]
assert _type(results[0]) == "tool.call.started"
assert results[0].data["tool_name"] == "Manual review"
assert all(_type(result) != "message.delta" for result in results)
assert _type(interaction_result) == "action.requested"
assert interaction_result.data["action"] == "interaction.requested"
request = interaction_result.data["payload"]
assert request["fields"][0]["options"][0]["value"] == "high"
assert request["actions"] == []
assert "form-private" not in str(request)
assert "workflow-private" not in str(request)
assert len(storage.values) == 1
continuation = json.loads(next(iter(storage.values.values())))
assert continuation["form_token"] == "form-private"
assert continuation["workflow_run_id"] == "workflow-private"
assert continuation["interaction_actions"][0]["label"] == "Approve"
assert all(_type(result) != "run.completed" for result in results)
assert requests[0].fields[0].options[0].value == 'high'
assert requests[1].actions[0].label == 'Approve'
assert 'form-private' not in str(requests)
assert 'workflow-private' not in str(requests)
assert storage.values == {}
assert _type(results[-1]) == 'run.completed'
def test_dify_current_human_input_event_requests_host_interaction() -> None:
@@ -278,18 +298,20 @@ def test_dify_current_human_input_event_requests_host_interaction() -> None:
},
}
results = asyncio.run(_collect_async(runner._run_workflow(_ctx(), FakeClient(), {}, "hello", "user_1", [], False)))
async def workflow_submit(self, **kwargs):
assert kwargs['inputs']['priority'] == 'high'
yield {'event': 'node_finished', 'data': {'node_type': 'answer', 'outputs': {'answer': 'Approved'}}}
yield {'event': 'workflow_finished', 'data': {'outputs': {}}}
assert _type(results[-1]) == "action.requested"
assert all(_type(result) != "run.completed" for result in results)
request = results[-1].data["payload"]
assert request["title"] == "Manual review"
assert request["fields"][0]["options"][0]["value"] == "high"
assert "form-private" not in str(request)
assert "workflow-private" not in str(request)
continuation = json.loads(next(iter(storage.values.values())))
assert continuation["form_token"] == "form-private"
assert continuation["workflow_run_id"] == "workflow-private"
ctx, requests = _dify_interaction_ctx()
results = asyncio.run(_collect_async(runner._run_workflow(ctx, FakeClient(), {}, "hello", "user_1", [], False)))
assert _type(results[-1]) == 'run.completed'
assert requests[0].title == 'Manual review'
assert requests[0].fields[0].options[0].value == 'high'
assert 'form-private' not in str(requests)
assert 'workflow-private' not in str(requests)
assert storage.values == {}
def test_dify_pause_accepts_single_reason_and_camel_case_fields() -> None:
@@ -3,6 +3,7 @@
from __future__ import annotations
import copy
import asyncio
import json
import time
import typing
@@ -10,6 +11,12 @@ import typing
import pydantic
from langbot_plugin.api.entities.builtin.platform import message as platform_message
from langbot_plugin.api.entities.builtin.runner.interaction import (
InteractionDeliveryCapabilities,
InteractionHandle,
InteractionRequest,
InteractionSubmission,
)
from .descriptor import RunnerDescriptor
from .errors import RunnerProtocolError
@@ -69,7 +76,7 @@ class HostInteractionRequest(pydantic.BaseModel):
fields: list[HostInteractionField] = pydantic.Field(default_factory=list, max_length=50)
actions: list[HostInteractionAction] = pydantic.Field(default_factory=list, max_length=20)
expires_at: int | None = None
fallback_text: str = pydantic.Field(min_length=1, max_length=20000)
fallback_text: str | None = pydantic.Field(default=None, min_length=1, max_length=20000)
model_config = pydantic.ConfigDict(extra='forbid')
@@ -81,6 +88,7 @@ class HostInteractionSubmission(pydantic.BaseModel):
action_id: str | None = pydantic.Field(default=None, max_length=128)
values: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
submitted_at: int | None = None
label: str | None = None
model_config = pydantic.ConfigDict(extra='forbid')
@@ -91,6 +99,342 @@ class InteractionManager:
def __init__(self, ap: typing.Any, store: InteractionStore | None = None):
self.ap = ap
self.store = store or InteractionStore(ap.persistence_mgr.get_db_engine())
self._waiters: dict[tuple[str, str], asyncio.Future[dict[str, typing.Any]]] = {}
self._waiter_scopes: dict[tuple[str, str], tuple[str, str]] = {}
self._responders: dict[tuple[str, str], str | list[str]] = {}
self._callback_contexts: dict[tuple[str, str], dict[str, typing.Any]] = {}
self._text_waiters: dict[tuple[str, str], tuple[str, str, str, str, InteractionRequest]] = {}
async def request_interaction(
self,
*,
session: typing.Mapping[str, typing.Any],
adapter: typing.Any,
request_payload: dict[str, typing.Any],
timeout: float,
on_unsupported: str,
responders: str | list[str],
target_type: str | None = None,
target_id: str | None = None,
) -> dict[str, typing.Any]:
"""Deliver an interaction and wait for its validated callback in this run."""
authorization = session['authorization']
if not authorization.get('interactions_enabled') or not authorization.get('interactions_authorized'):
raise ValueError('Runner did not declare interaction capability and permission')
if not 0 < timeout <= MAX_INTERACTION_TTL_SECONDS:
raise ValueError('Interaction timeout must be within 24 hours')
if on_unsupported not in {'error', 'degrade'}:
raise ValueError('Invalid interaction fallback policy')
valid_responders = (
responders in {'actor', 'any'}
if isinstance(responders, str)
else (
isinstance(responders, list)
and bool(responders)
and all(isinstance(item, str) and item for item in responders)
)
)
if not valid_responders:
raise ValueError('Invalid interaction responders')
request = InteractionRequest.model_validate(request_payload)
if request.fields and request.actions:
raise ValueError('Interaction cannot combine fields and actions')
self._validate_request_identity(
HostInteractionRequest.model_validate(request.model_dump(mode='json')), session['runner_id']
)
run_id = session['run_id']
key = (run_id, request.interaction_id)
bot_id = authorization.get('bot_id')
workspace_id = authorization.get('workspace_id')
workspace_waiters = sum(scope[0] == workspace_id for scope in self._waiter_scopes.values())
bot_waiters = sum(scope == (workspace_id, bot_id) for scope in self._waiter_scopes.values())
if workspace_waiters >= 256 or bot_waiters >= 32:
raise ValueError('Too many pending interactions')
if key in self._waiters:
raise ValueError('Interaction is already waiting')
platform_context = authorization.get('platform_context') or {}
delivery = platform_context.get('delivery') or {}
reply_target = delivery.get('reply_target') or {}
selected_type = target_type or reply_target.get('target_type')
selected_id = target_id or reply_target.get('target_id')
if not selected_type or not selected_id:
raise ValueError('Interaction has no delivery target')
if target_type is not None and (target_type, target_id) != (
reply_target.get('target_type'),
reply_target.get('target_id'),
):
from .platform_tools import resolve_platform_api_call
resolve_platform_api_call(
session,
bot_id,
'send_message',
{
'target_type': target_type,
'target_id': target_id,
'message': platform_message.MessageChain(
[platform_message.Plain(text='Interaction request')]
).model_dump(mode='json'),
},
)
actor = platform_context.get('actor') or {}
actor_id = actor.get('actor_id')
if responders == 'actor' and not actor_id:
raise ValueError('Interaction has no actor')
capabilities = adapter.get_interaction_capabilities()
if not isinstance(capabilities, InteractionDeliveryCapabilities):
capabilities = InteractionDeliveryCapabilities.model_validate(capabilities)
supported_apis = adapter.get_supported_apis() if callable(getattr(adapter, 'get_supported_apis', None)) else []
if supported_apis and 'interaction.acknowledge' not in supported_apis:
capabilities.supports_updates = False
normalized, degraded, use_text, issues = self._normalize_request(request, capabilities, on_unsupported)
if issues:
return {'unsupported': issues}
request_data = normalized.model_dump(mode='json')
request_data['expires_at'] = int(time.time() + timeout + 1)
if len(json.dumps(request_data, ensure_ascii=False).encode('utf-8')) > INTERACTION_PAYLOAD_MAX_BYTES:
raise ValueError('Interaction request exceeds 256 KiB')
conversation_id = f'{selected_type}_{selected_id}'
_, callback_token = await self.store.create_request(
interaction_id=request.interaction_id,
run_id=run_id,
binding_id=authorization['binding_id'],
runner_id=session['runner_id'],
processor_type=authorization['processor_type'],
processor_id=authorization['processor_id'],
request=request_data,
delivery_target={'target_type': selected_type, 'target_id': selected_id},
bot_id=bot_id,
workspace_id=workspace_id,
conversation_id=conversation_id,
thread_id=authorization.get('thread_id'),
actor_id=actor_id if responders == 'actor' else None,
expires_at=request_data['expires_at'],
blocking=True,
**self._pipeline_tool_admission(session, authorization, session['runner_id']),
)
future = asyncio.get_running_loop().create_future()
self._waiters[key] = future
self._responders[key] = responders
self._waiter_scopes[key] = (workspace_id, bot_id)
if use_text:
self._text_waiters[key] = (workspace_id, bot_id, conversation_id, callback_token, normalized)
runtime = session.get('runtime_context')
original_deadline = runtime.get('deadline_at') if isinstance(runtime, dict) else None
started_at = time.time()
if original_deadline is not None:
runtime['deadline_at'] = float(original_deadline) + timeout + 30
handle = None
terminal = None
try:
if use_text:
await adapter.send_message(
str(selected_type),
str(selected_id),
platform_message.MessageChain(
[platform_message.Plain(text=normalized.fallback_text or normalized.title)]
),
)
else:
handle = await adapter.send_interaction(
str(selected_type),
str(selected_id),
normalized,
callback_token,
reply_to=reply_target.get('message_id'),
)
if isinstance(handle, dict):
handle = InteractionHandle.model_validate(handle)
await self.store.record_delivery_success(run_id, request.interaction_id, handle.model_dump(mode='json'))
try:
submission = await asyncio.wait_for(future, timeout=timeout)
terminal = 'submitted'
except asyncio.TimeoutError:
terminal = 'expired'
await self.store.mark_terminal(run_id, request.interaction_id, terminal)
submission = None
if handle is not None and capabilities.supports_updates:
handle.context['callback_context'] = self._callback_contexts.get(key, {})
try:
await adapter.update_interaction(
handle,
terminal,
InteractionSubmission.model_validate(submission) if submission else None,
)
except Exception as exc:
self._warning(f'Failed to update interaction card: {exc}')
if original_deadline is not None:
runtime['deadline_at'] = float(original_deadline) + time.time() - started_at
return {
'status': 'timeout' if terminal == 'expired' else 'submitted',
'submission': submission,
'degraded': degraded,
'deadline_at': runtime.get('deadline_at') if isinstance(runtime, dict) else None,
}
except asyncio.CancelledError:
terminal = 'cancelled'
await self.store.mark_terminal(run_id, request.interaction_id, terminal)
if handle is not None and capabilities.supports_updates:
try:
await adapter.update_interaction(handle, terminal)
except Exception as exc:
self._warning(f'Failed to cancel interaction card: {exc}')
raise
except Exception as exc:
if terminal is None:
await self.store.mark_delivery_failed(run_id, request.interaction_id, str(exc))
raise
finally:
if original_deadline is not None:
runtime['deadline_at'] = float(original_deadline) + time.time() - started_at
self._waiters.pop(key, None)
self._responders.pop(key, None)
self._callback_contexts.pop(key, None)
self._text_waiters.pop(key, None)
self._waiter_scopes.pop(key, None)
@staticmethod
def _normalize_request(request, capabilities, policy):
normalized = request.model_copy(deep=True)
issues = []
degraded = []
native = bool(capabilities.field_types or capabilities.max_actions or capabilities.action_styles)
if not native:
issues.append({'path': 'platform', 'reason': 'native_interactions_unavailable'})
for index, field in enumerate(request.fields):
if field.type not in capabilities.field_types:
issues.append({'path': f'fields[{index}].type', 'reason': 'unsupported_field_type'})
if capabilities.max_options is not None and len(field.options) > capabilities.max_options:
issues.append({'path': f'fields[{index}].options', 'reason': 'too_many_options'})
if field.placeholder:
degraded.append(
{'path': f'fields[{index}].placeholder', 'reason': 'unsupported_placeholder', 'applied': 'omit'}
)
normalized.fields[index].placeholder = None
if field.default is not None:
degraded.append(
{'path': f'fields[{index}].default', 'reason': 'unsupported_default', 'applied': 'omit'}
)
normalized.fields[index].default = None
if capabilities.max_fields is not None and len(request.fields) > capabilities.max_fields:
issues.append({'path': 'fields', 'reason': 'too_many_fields'})
if capabilities.max_actions is not None and len(request.actions) > capabilities.max_actions:
issues.append({'path': 'actions', 'reason': 'too_many_actions'})
for index, action in enumerate(request.actions):
if action.style not in capabilities.action_styles:
degraded.append(
{'path': f'actions[{index}].style', 'reason': 'unsupported_style', 'applied': 'default'}
)
normalized.actions[index].style = 'default'
if request.description and not capabilities.supports_markdown:
degraded.append({'path': 'description', 'reason': 'unsupported_markdown', 'applied': 'literal_text'})
if issues and policy == 'error':
return normalized, degraded, False, issues
if issues and (
not capabilities.text_fallback
or len(request.fields) > 1
or any(field.type in {'file', 'multiselect'} for field in request.fields)
):
return normalized, degraded, False, issues
use_text = bool(issues)
fallback_text = request.fallback_text or request.title
if use_text and request.actions:
choices = ' / '.join(f'{index + 1} {action.label}' for index, action in enumerate(request.actions))
fallback_text = f'{fallback_text}\n{choices}'
elif use_text and request.fields:
field = request.fields[0]
fallback_text = f'{fallback_text}\n{field.label}'
if field.type == 'select':
fallback_text += '\n' + ' / '.join(
f'{index + 1} {option.label}' for index, option in enumerate(field.options)
)
normalized.fallback_text = fallback_text
return (
normalized,
degraded
+ (
[{'path': 'platform', 'reason': 'unsupported_structure', 'applied': 'text_fallback'}]
if use_text
else []
),
use_text,
[],
)
def wake_waiter(
self,
record: dict[str, typing.Any],
actor_id: str | None,
callback_context: dict[str, typing.Any] | None = None,
) -> bool:
key = (record['run_id'], record['interaction_id'])
future = self._waiters.get(key)
if future is None or future.done():
return False
responders = self._responders[key]
if isinstance(responders, list) and actor_id not in responders:
raise ValueError('Interaction actor is not an allowed responder')
self._callback_contexts[key] = callback_context or {}
future.set_result(record['submission'])
return True
async def consume_text_message(
self,
*,
workspace_id: str,
bot_id: str,
conversation_id: str,
actor_id: str,
text: str,
) -> bool:
answer = text.strip()
if not answer or len(answer) > 20000:
return False
for key, (workspace, bot, conversation, token, request) in reversed(list(self._text_waiters.items())):
if (workspace, bot, conversation) != (workspace_id, bot_id, conversation_id):
continue
responders = self._responders.get(key)
if responders == 'actor':
pending = await self.store.get_by_callback_token(token)
if pending is None or pending.get('actor_id') != actor_id:
continue
elif isinstance(responders, list) and actor_id not in responders:
continue
submission: dict[str, typing.Any] = {'interaction_id': request.interaction_id, 'values': {}}
if request.actions:
if not answer.isdigit() or not 1 <= int(answer) <= len(request.actions):
return False
submission['action_id'] = request.actions[int(answer) - 1].id
submission['label'] = request.actions[int(answer) - 1].label
elif request.fields:
field = request.fields[0]
value: typing.Any = answer
if field.type == 'number':
try:
value = float(answer)
except ValueError:
return False
elif field.type == 'boolean':
if answer.lower() not in {'yes', 'no', 'true', 'false', '1', '0'}:
return False
value = answer.lower() in {'yes', 'true', '1'}
elif field.type == 'select':
options = field.options
if answer.isdigit() and 1 <= int(answer) <= len(options):
value = options[int(answer) - 1].value
elif answer not in {option.value for option in options}:
return False
submission['values'] = {field.id: value}
record = await self.consume_callback(
callback_token=token,
submission=submission,
bot_id=bot_id,
conversation_id=conversation_id,
actor_id=actor_id,
)
return self.wake_waiter(record, actor_id)
return False
async def handle_result(
self,
@@ -496,6 +840,9 @@ class InteractionManager:
from .interaction_store import InteractionNotFoundError
raise InteractionNotFoundError('Unknown interaction callback token')
responders = self._responders.get((pending['run_id'], pending['interaction_id']))
if isinstance(responders, list) and actor_id not in responders:
raise ValueError('Interaction actor is not an allowed responder')
normalized_submission = self._resolve_submission_refs(pending, submission)
try:
normalized = HostInteractionSubmission.model_validate(normalized_submission)
@@ -126,6 +126,7 @@ class InteractionStore:
expires_at: int | float | None = None,
expected_config: dict | None = None,
authority_check: typing.Callable[[], bool] | None = None,
blocking: bool = False,
) -> tuple[dict[str, typing.Any], str]:
"""Persist a request and return its record plus one-time callback token."""
if not interaction_id or not run_id or not binding_id or not runner_id or not processor_id:
@@ -154,6 +155,7 @@ class InteractionStore:
thread_id=thread_id,
actor_id=actor_id,
status='pending',
status_reason='blocking' if blocking else None,
request_json=request_json,
delivery_target_json=delivery_target_json,
replaces_interaction_id=replaces_interaction_id,
@@ -491,6 +493,36 @@ class InteractionStore:
await session.commit()
return result.rowcount == 1
async def mark_terminal(self, run_id: str, interaction_id: str, status: str) -> bool:
if status not in {'expired', 'cancelled'}:
raise ValueError('Invalid terminal interaction status')
now = _utc_now()
async with self._session_factory() as session:
result = await session.execute(
sqlalchemy.update(AgentInteraction)
.where(
AgentInteraction.run_id == run_id,
AgentInteraction.interaction_id == interaction_id,
AgentInteraction.status == 'pending',
)
.values(status=status, status_reason=f'interaction {status}', updated_at=_db_datetime(now))
)
await session.commit()
return result.rowcount == 1
async def cancel_orphaned_pending(self, active_run_ids: set[str]) -> int:
"""Invalidate pending callbacks after Host restart or run cleanup."""
now = _utc_now()
condition = ~AgentInteraction.run_id.in_(active_run_ids) if active_run_ids else sqlalchemy.true()
async with self._session_factory() as session:
result = await session.execute(
sqlalchemy.update(AgentInteraction)
.where(AgentInteraction.status == 'pending', AgentInteraction.status_reason == 'blocking', condition)
.values(status='cancelled', status_reason='run is no longer active', updated_at=_db_datetime(now))
)
await session.commit()
return int(result.rowcount or 0)
async def expire_pending(self, *, now: datetime.datetime | None = None) -> int:
"""Expire pending interactions whose deadline has elapsed."""
cutoff = _as_utc(now) or _utc_now()
+17 -3
View File
@@ -94,17 +94,31 @@ class RunnerInvoker:
await self._close_generator(gen, descriptor)
raise asyncio.TimeoutError
next_result = asyncio.create_task(anext(gen))
try:
if remaining is None:
return await anext(gen)
return await asyncio.wait_for(anext(gen), timeout=remaining)
while True:
if remaining is None:
return await next_result
try:
return await asyncio.wait_for(asyncio.shield(next_result), timeout=remaining)
except asyncio.TimeoutError:
# The Host can extend the deadline while a blocking interaction
# is active. Keep the same pending read when that happens.
renewed = self._remaining_deadline_seconds(context)
if renewed is None or renewed <= 0:
raise
remaining = renewed
except StopAsyncIteration:
if self._is_deadline_exhausted(context):
raise asyncio.TimeoutError
raise
except asyncio.TimeoutError:
next_result.cancel()
await self._close_generator(gen, descriptor)
raise
finally:
if not next_result.done():
next_result.cancel()
def _remaining_deadline_seconds(
self,
@@ -313,6 +313,11 @@ class AgentRunOrchestrator:
processor_type=binding.processor_type,
processor_id=binding.processor_id or binding.agent_id,
interactions_enabled=binding.delivery_policy.enable_interactions,
interactions_authorized=(
bool(getattr(descriptor.capabilities, 'interactions', False))
and 'request' in (getattr(descriptor.permissions, 'interactions', []) or [])
),
runtime_context=context.get('runtime'),
)
event_log_id = await self.journal.write_event_log(
+16 -8
View File
@@ -46,7 +46,7 @@ _TARGET_TYPE = {'type': 'string', 'enum': ['person', 'group']}
_CHAT_TYPE = {'type': 'string', 'enum': ['person', 'private', 'group']}
_APPROVE = {'type': 'boolean', 'description': 'true to accept; false to reject'}
PRESENT_INTERACTIVE_CARD_TOOL = 'present_interactive_card'
PRESENT_INTERACTIVE_CARD_TOOL = 'request_interaction'
_INTERACTION_OPTION_SCHEMA = {
'type': 'object',
@@ -94,13 +94,13 @@ PLATFORM_TOOL_DEFINITIONS: tuple[PlatformToolDefinition, ...] = (
'event',
'interaction',
'write',
{'en_US': 'Present interactive card'},
{'en_US': 'Request user interaction'},
{
'en_US': (
'Present a native interactive card in the current conversation. Define semantic buttons, selects, '
'and input fields; LangBot owns the target, callback token, validation, and platform rendering. '
'Each card is one step: provide either actions or fields, never both. '
'The user submission will start a new interaction.submitted turn for this processor.'
'The tool waits for the user and returns the structured submission in this run.'
),
},
_object_schema(
@@ -111,6 +111,7 @@ PLATFORM_TOOL_DEFINITIONS: tuple[PlatformToolDefinition, ...] = (
'fields': {'type': 'array', 'items': _INTERACTION_FIELD_SCHEMA, 'maxItems': 50},
'actions': {'type': 'array', 'items': _INTERACTION_ACTION_SCHEMA, 'maxItems': 20},
'expires_in_seconds': {'type': 'integer', 'minimum': 60, 'maximum': 86400},
'responders': {'type': 'string', 'enum': ['actor', 'any']},
},
['title'],
),
@@ -536,10 +537,14 @@ def build_platform_tool_resources(
reason = None
if definition is None:
reason = 'unknown_tool'
elif definition.api not in supported_apis and not (
definition.name == 'event_reply'
and {'reply_message', 'get_message'} <= supported_apis
and (event.delivery.reply_target or {}).get('message_id')
elif (
definition.api not in supported_apis
and not (definition.name == PRESENT_INTERACTIVE_CARD_TOOL and 'send_message' in supported_apis)
and not (
definition.name == 'event_reply'
and {'reply_message', 'get_message'} <= supported_apis
and (event.delivery.reply_target or {}).get('message_id')
)
):
reason = 'adapter_api_unsupported'
elif definition.scope == 'event' and not _event_matches(event.event_type, definition.event_patterns):
@@ -873,10 +878,13 @@ async def execute_platform_tool(
manager = getattr(getattr(ap, 'agent_run_orchestrator', None), 'interaction_manager', None)
if manager is None:
raise ValueError('Interactive card delivery is unavailable')
return await manager.present_tool_request(
return await manager.request_interaction(
request_payload=_interactive_card_request(normalized),
session=session,
adapter=bot.adapter,
timeout=normalized.get('expires_in_seconds', 600),
on_unsupported='degrade',
responders=normalized.get('responders', 'actor'),
)
api_func = getattr(bot.adapter, definition.api, None)
if reply_via_source:
@@ -91,7 +91,12 @@ class AgentResourceBuilder:
)
runner_uses_host_tools = config_schema.uses_host_tools(descriptor)
selected_platform_tools = list(resource_policy.allowed_platform_tool_names or [])
if binding.processor_type not in {'agent', 'pipeline'} or not binding.delivery_policy.enable_interactions:
if (
binding.processor_type not in {'agent', 'pipeline'}
or not binding.delivery_policy.enable_interactions
or not getattr(descriptor.capabilities, 'interactions', False)
or 'request' not in (getattr(manifest_perms, 'interactions', []) or [])
):
selected_platform_tools = [
name for name in selected_platform_tools if name != PRESENT_INTERACTIVE_CARD_TOOL
]
@@ -54,6 +54,7 @@ class RunAuthorizationSnapshot(typing.TypedDict):
processor_type: str | None
processor_id: str | None
interactions_enabled: bool
interactions_authorized: bool
SteeringQueueItem = dict[str, typing.Any]
@@ -83,6 +84,7 @@ class AgentRunSession(typing.TypedDict):
status: AgentRunSessionStatus
reply_streams: typing.Any
steering_queue: list[SteeringQueueItem]
runtime_context: dict[str, typing.Any] | None
class AgentRunSessionRegistry:
@@ -125,6 +127,8 @@ class AgentRunSessionRegistry:
processor_type: str | None = None,
processor_id: str | None = None,
interactions_enabled: bool = False,
interactions_authorized: bool = False,
runtime_context: dict[str, typing.Any] | None = None,
) -> None:
"""Register a new agent run session.
@@ -174,6 +178,7 @@ class AgentRunSessionRegistry:
'processor_type': processor_type,
'processor_id': processor_id,
'interactions_enabled': interactions_enabled,
'interactions_authorized': interactions_authorized,
}
session: AgentRunSession = {
@@ -189,6 +194,7 @@ class AgentRunSessionRegistry:
'last_activity_at': now,
},
'steering_queue': [],
'runtime_context': runtime_context,
}
async with self._lock:
+1
View File
@@ -347,6 +347,7 @@ class BuildAppStage(stage.BootingStage):
agent_run_orchestrator_inst = AgentRunOrchestrator(ap, runner_registry_inst)
ap.agent_run_orchestrator = agent_run_orchestrator_inst
await agent_run_orchestrator_inst.interaction_manager.store.cancel_orphaned_pending(set())
ctrl = controller.Controller(ap)
ap.ctrl = ctrl
@@ -15,6 +15,7 @@ from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
from langbot.pkg.platform.adapters.dingtalk.api_impl import DingTalkAPIMixin
from langbot.pkg.platform.adapters.interaction_api import InteractionAdapterMixin
from langbot.pkg.platform.adapters.dingtalk.event_converter import DingTalkEventConverter
from langbot.pkg.platform.adapters.dingtalk.message_converter import DingTalkMessageConverter
from langbot.pkg.platform.adapters.dingtalk.platform_api import PLATFORM_API_MAP
@@ -92,7 +93,7 @@ class DingTalkCardCallbackHandler(dingtalk_stream.CallbackHandler):
return dingtalk_stream.AckMessage.STATUS_OK, 'OK'
class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter):
class DingTalkAdapter(DingTalkAPIMixin, InteractionAdapterMixin, abstract_platform_adapter.AbstractPlatformAdapter):
bot: DingTalkClient = pydantic.Field(exclude=True)
message_converter: DingTalkMessageConverter = DingTalkMessageConverter()
@@ -165,9 +166,9 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
'call_platform_api',
]
if hasattr(self.bot, 'create_and_deliver_ai_card'):
apis.append('interaction.request')
apis.extend(['interaction.request', 'send_interaction', 'get_interaction_capabilities'])
if hasattr(self.bot, 'update_ai_card'):
apis.append('interaction.acknowledge')
apis.extend(['interaction.acknowledge', 'update_interaction'])
return apis
def get_interaction_capabilities(self) -> dict[str, typing.Any]:
@@ -303,7 +303,7 @@ async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) -
'field_types': field_types,
'target_type': target_type,
'target_id': target_id,
'expires_at': time.monotonic() + 30 * 60,
'expires_at': time.monotonic() + max(1, int(request.get('expires_at') or time.time() + 30 * 60) - time.time()),
}
result = {
'ok': True,
@@ -11,6 +11,7 @@ import pydantic
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
from langbot.pkg.platform.adapters.discord.api_impl import DiscordAPIMixin
from langbot.pkg.platform.adapters.interaction_api import InteractionAdapterMixin
from langbot.pkg.platform.adapters.discord.event_converter import DiscordEventConverter
from langbot.pkg.platform.adapters.discord.message_converter import DiscordMessageConverter
from langbot.pkg.platform.adapters.discord.platform_api import PLATFORM_API_MAP
@@ -24,7 +25,7 @@ from langbot_plugin.api.entities.builtin.platform import events as platform_even
from langbot_plugin.api.entities.builtin.platform import message as platform_message
class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter):
class DiscordAdapter(DiscordAPIMixin, InteractionAdapterMixin, abstract_platform_adapter.AbstractPlatformAdapter):
bot: discord.Client = pydantic.Field(exclude=True)
message_converter: DiscordMessageConverter = DiscordMessageConverter()
@@ -205,6 +206,8 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
'leave_group',
'call_platform_api',
'interaction.request',
'send_interaction',
'get_interaction_capabilities',
]
def get_interaction_capabilities(self) -> dict[str, typing.Any]:
@@ -0,0 +1,55 @@
"""Standard interaction adapter methods backed by existing platform renderers."""
from __future__ import annotations
from langbot_plugin.api.entities.builtin.runner.interaction import (
InteractionHandle,
InteractionRequest,
InteractionSubmission,
)
class InteractionAdapterMixin:
async def send_interaction(
self,
target_type: str,
target_id: str,
request: InteractionRequest,
callback_token: str,
reply_to: str | None = None,
) -> InteractionHandle:
result = await self.call_platform_api(
'interaction.request',
{
'request': request.model_dump(mode='json'),
'callback_token': callback_token,
'reply_target': {
'target_type': target_type,
'target_id': target_id,
'message_id': reply_to,
},
},
)
handle = InteractionHandle.model_validate(result)
handle.context['request'] = request.model_dump(mode='json')
return handle
async def update_interaction(
self,
handle: InteractionHandle,
state: str,
submission: InteractionSubmission | None = None,
) -> InteractionHandle:
if 'interaction.acknowledge' not in self.get_supported_apis():
return handle
result = await self.call_platform_api(
'interaction.acknowledge',
{
'update_target': handle.model_dump(mode='json'),
'request': handle.context.get('request') or {},
'callback_context': handle.context.get('callback_context') or {},
'submission': submission.model_dump(mode='json') if submission else {},
'state': state,
},
)
return InteractionHandle.model_validate(result) if isinstance(result, dict) else handle
@@ -1,6 +1,7 @@
from __future__ import annotations
from langbot.pkg.i18n.adapters import adapter_t, localized_adapter
from langbot.pkg.platform.adapters.interaction_api import InteractionAdapterMixin
from langbot.pkg.platform.sources.lark import (
LarkAdapter as LegacyLarkAdapter,
@@ -113,7 +114,7 @@ async def _cancel_ws_cache_task(client: typing.Any) -> None:
await asyncio.gather(cache_task, return_exceptions=True)
class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter):
class LarkAdapter(LarkAPIMixin, InteractionAdapterMixin, abstract_platform_adapter.AbstractPlatformAdapter):
bot: lark_oapi.ws.Client = pydantic.Field(exclude=True)
api_client: lark_oapi.Client = pydantic.Field(exclude=True)
quart_app: quart.Quart = pydantic.Field(exclude=True)
@@ -245,6 +246,9 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
'call_platform_api',
'interaction.request',
'interaction.acknowledge',
'send_interaction',
'update_interaction',
'get_interaction_capabilities',
]
def get_interaction_capabilities(self) -> dict[str, typing.Any]:
@@ -10,6 +10,7 @@ import pydantic
from langbot.libs.qq_official_api.api import QQOfficialClient
from langbot.libs.qq_official_api.qqofficialevent import QQOfficialEvent
from langbot.pkg.platform.adapters.qqofficial.api_impl import QQOfficialAPIMixin
from langbot.pkg.platform.adapters.interaction_api import InteractionAdapterMixin
from langbot.pkg.platform.adapters.qqofficial.errors import NotSupportedError
from langbot.pkg.platform.adapters.qqofficial.event_converter import (
BOT_INVITED_EVENT_TYPES,
@@ -35,7 +36,7 @@ from langbot_plugin.api.entities.builtin.platform import events as platform_even
from langbot_plugin.api.entities.builtin.platform import message as platform_message
class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter):
class QQOfficialAdapter(QQOfficialAPIMixin, InteractionAdapterMixin, abstract_platform_adapter.AbstractPlatformAdapter):
bot: typing.Any = pydantic.Field(exclude=True)
message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter()
@@ -126,6 +127,8 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
'get_group_member_info',
'call_platform_api',
'interaction.request',
'send_interaction',
'get_interaction_capabilities',
]
def get_interaction_capabilities(self) -> dict[str, typing.Any]:
@@ -30,6 +30,7 @@ import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
from langbot.pkg.platform.adapters.telegram.message_converter import TelegramMessageConverter
from langbot.pkg.platform.adapters.interaction_api import InteractionAdapterMixin
from langbot.pkg.platform.adapters.telegram.event_converter import TelegramEventConverter, LegacyEventConverter
from langbot.pkg.platform.adapters.telegram.api_impl import TelegramAPIMixin
from langbot.pkg.platform.adapters.telegram.platform_api import PLATFORM_API_MAP
@@ -41,7 +42,7 @@ from langbot.pkg.platform.adapters.telegram.interaction import (
)
class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter):
class TelegramAdapter(TelegramAPIMixin, InteractionAdapterMixin, abstract_platform_adapter.AbstractPlatformAdapter):
"""Telegram adapter (EBA version)."""
bot: telegram.Bot = pydantic.Field(exclude=True)
@@ -199,6 +200,8 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
'leave_group',
'call_platform_api',
'interaction.request',
'send_interaction',
'get_interaction_capabilities',
]
def get_interaction_capabilities(self) -> dict[str, typing.Any]:
@@ -14,6 +14,7 @@ from langbot.libs.wecom_ai_bot_api.api import WecomBotClient
from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient
from langbot.pkg.platform.adapters.wecombot.api_impl import WecomBotAPIMixin
from langbot.pkg.platform.adapters.interaction_api import InteractionAdapterMixin
from langbot.pkg.platform.adapters.wecombot.event_converter import WecomBotEventConverter
from langbot.pkg.platform.adapters.wecombot.message_converter import WecomBotMessageConverter
from langbot.pkg.platform.adapters.wecombot.platform_api import PLATFORM_API_MAP
@@ -31,7 +32,7 @@ from langbot_plugin.api.entities.builtin.platform import message as platform_mes
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter):
class WecomBotAdapter(WecomBotAPIMixin, InteractionAdapterMixin, abstract_platform_adapter.AbstractPlatformAdapter):
bot: typing.Any = pydantic.Field(exclude=True)
message_converter: WecomBotMessageConverter = WecomBotMessageConverter()
@@ -124,7 +125,15 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
'call_platform_api',
]
if not self.config.get('enable-webhook', False) and hasattr(self.bot, 'send_template_card'):
apis.extend(['interaction.request', 'interaction.acknowledge'])
apis.extend(
[
'interaction.request',
'interaction.acknowledge',
'send_interaction',
'update_interaction',
'get_interaction_capabilities',
]
)
return apis
def get_interaction_capabilities(self) -> dict[str, typing.Any]:
+80 -4
View File
@@ -35,7 +35,8 @@ from ..agent.runner.host_models import (
from ..agent.runner.resource_policy import ResourcePolicyProjector
from ..agent.runner.platform_tools import resolve_agent_platform_tool_names
from ..agent.runner.query_entry_adapter import QueryEntryAdapter
from ..agent.runner.interaction_store import InteractionAlreadySubmittedError
from ..agent.runner.interaction_store import InteractionAlreadySubmittedError, InteractionExpiredError
from langbot_plugin.api.entities.builtin.runner.interaction import InteractionHandle
from ..entity.persistence import workspace as persistence_workspace
from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType, RequestContext
@@ -1031,12 +1032,29 @@ class RuntimeBot:
) -> None:
results: list[typing.Any] = []
try:
if isinstance(event, platform_events.PlatformSpecificEvent) and event.action == 'interaction.submitted':
callback_data = event.data if isinstance(event.data, dict) else {}
event = platform_events.InteractionCallbackEvent(
callback_token=str(callback_data.get('callback_token') or ''),
values=callback_data.get('values') if isinstance(callback_data.get('values'), dict) else {},
action_id=callback_data.get('action_id'),
actor_id=str(callback_data.get('actor_id') or callback_data.get('user_id') or '') or None,
target_type=callback_data.get('target_type'),
target_id=callback_data.get('target_id') or callback_data.get('chat_id'),
raw_submission=callback_data,
timestamp=event.timestamp,
bot_uuid=event.bot_uuid,
adapter_name=event.adapter_name,
source_platform_object=event.source_platform_object,
)
await self._record_adapter_event(event, adapter)
await self._persist_monitoring_ingress(event, adapter, execution_id)
primary_handler = (
self._handle_interaction_submission
if isinstance(event, platform_events.PlatformSpecificEvent) and event.action == 'interaction.submitted'
if isinstance(event, platform_events.InteractionCallbackEvent)
or isinstance(event, platform_events.PlatformSpecificEvent)
and event.action == 'interaction.submitted'
else self._dispatch_eba_event_to_processor
)
subscriptions = self._get_event_bindings_from_value(getattr(self.bot_entity, 'plugin_processors', []))
@@ -1141,6 +1159,28 @@ class RuntimeBot:
) -> dict[str, typing.Any]:
event_type = getattr(event, 'type', None) or event.__class__.__name__
if isinstance(event, platform_events.MessageReceivedEvent):
interaction_manager = getattr(getattr(self.ap, 'agent_run_orchestrator', None), 'interaction_manager', None)
else:
interaction_manager = None
if callable(getattr(interaction_manager, 'consume_text_message', None)):
target_type_for_text, target_id_for_text, _ = self._infer_reply_target(event)
sender_for_text = getattr(event, 'sender', None) or getattr(event, 'user', None)
actor_id_for_text = self._get_entity_id(sender_for_text)
text_for_interaction = ''.join(
part.text for part in event.message_chain if isinstance(part, platform_message.Plain)
)
if target_type_for_text and target_id_for_text and actor_id_for_text and text_for_interaction:
consumed = await interaction_manager.consume_text_message(
workspace_id=self.execution_context.workspace_uuid,
bot_id=self.bot_entity.uuid,
conversation_id=f'{target_type_for_text}_{target_id_for_text}',
actor_id=str(actor_id_for_text),
text=text_for_interaction,
)
if consumed:
return {'status': 'delivered', 'interaction': 'text_reply'}
if event_binding is None:
event_binding = self._resolve_eba_event_binding(event, event_type)
if event_binding is None:
@@ -1547,12 +1587,24 @@ class RuntimeBot:
async def _handle_interaction_submission(
self,
event: platform_events.PlatformSpecificEvent,
event: platform_events.PlatformSpecificEvent | platform_events.InteractionCallbackEvent,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
execution_id: str | None = None,
) -> None:
"""Consume a platform interaction callback and resume its original processor."""
data = event.data if isinstance(event.data, dict) else {}
data = (
event.data
if isinstance(event, platform_events.PlatformSpecificEvent) and isinstance(event.data, dict)
else {
**event.raw_submission,
'callback_token': event.callback_token,
'action_id': event.action_id,
'values': event.values,
'actor_id': event.actor_id,
'target_type': event.target_type,
'target_id': event.target_id,
}
)
callback_token = str(data.get('callback_token') or '')
actor_id = str(data.get('actor_id') or data.get('user_id') or '') or None
target_type = str(data.get('target_type') or '') or None
@@ -1580,6 +1632,30 @@ class RuntimeBot:
# Platform retries and repeated clicks are successful no-ops. The
# first callback remains the only turn allowed to resume execution.
return
except InteractionExpiredError:
stale = await self.ap.agent_run_orchestrator.interaction_manager.store.get_by_callback_token(callback_token)
delivery_result = stale.get('delivery_result') if stale else None
if isinstance(delivery_result, dict) and callable(getattr(adapter, 'update_interaction', None)):
try:
handle = InteractionHandle.model_validate(delivery_result)
handle.context['callback_context'] = {
'req_id': data.get('callback_req_id'),
'task_id': data.get('callback_task_id'),
}
await adapter.update_interaction(handle, 'cancelled')
except Exception as exc:
await self.logger.warning(f'Failed to invalidate stale interaction card: {exc}')
return
wake_waiter = getattr(self.ap.agent_run_orchestrator.interaction_manager, 'wake_waiter', None)
if callable(wake_waiter) and wake_waiter(
record,
actor_id,
{
'req_id': data.get('callback_req_id'),
'task_id': data.get('callback_task_id'),
},
):
return
if record['processor_type'] not in {'agent', 'pipeline'}:
raise ValueError(f'Unsupported interaction processor type: {record["processor_type"]}')
from ..telemetry import trace as trace_mod
+35
View File
@@ -1364,6 +1364,41 @@ class RuntimeConnectionHandler(handler.Handler):
},
)
@self.action(PluginToRuntimeAction.REQUEST_INTERACTION)
async def request_interaction(data: dict[str, Any]) -> handler.ActionResponse:
action_context, _ = await self._require_plugin_action_context()
session, error = await _validate_agent_run_session(
data.get('run_id'), data.get('caller_plugin_identity'), self.ap, 'request_interaction'
)
if error:
return error
authorization = session['authorization']
if authorization.get('workspace_id') != action_context.workspace_uuid:
return handler.ActionResponse.error('Run belongs to another Workspace')
if not authorization.get('interactions_enabled') or not authorization.get('interactions_authorized'):
return handler.ActionResponse.error('Runner did not declare interaction capability and permission')
bot_id = authorization.get('bot_id')
if not bot_id:
return handler.ActionResponse.error('Interaction requires a platform bot')
bot = await self.ap.platform_mgr.get_bot_by_uuid(self._execution_context(action_context), bot_id)
if bot is None:
return handler.ActionResponse.error('Interaction bot is not running')
manager = self.ap.agent_run_orchestrator.interaction_manager
try:
result = await manager.request_interaction(
session=session,
adapter=bot.adapter,
request_payload=data['request'],
timeout=float(data['timeout']),
on_unsupported=data.get('on_unsupported', 'error'),
responders=data.get('responders', 'actor'),
target_type=data.get('target_type'),
target_id=data.get('target_id'),
)
return handler.ActionResponse.success(data=result)
except (ValueError, KeyError, TypeError, pydantic.ValidationError) as exc:
return handler.ActionResponse.error(str(exc))
@self.action(PluginToRuntimeAction.GET_LLM_MODELS)
async def get_llm_models(data: dict[str, Any]) -> handler.ActionResponse:
"""Get llm models, returns list of UUID strings"""
@@ -3,6 +3,7 @@
from __future__ import annotations
from types import SimpleNamespace
import asyncio
import time
import pytest
@@ -14,6 +15,7 @@ from langbot_plugin.api.entities.builtin.runner.event import ActorContext
from langbot_plugin.api.entities.builtin.runner.input import AgentInput
from langbot_plugin.api.entities.builtin.runner.interaction import (
InteractionDeliveryCapabilities,
InteractionHandle,
InteractionSubmission,
)
@@ -765,3 +767,295 @@ async def test_callback_rejects_option_not_present_in_request(store, event, bind
conversation_id='group_chat-1',
actor_id='user-1',
)
class BlockingAdapter:
def __init__(self):
self.sent = asyncio.Event()
self.token = None
self.updates = []
def get_interaction_capabilities(self):
return InteractionDeliveryCapabilities(
action_styles=['default', 'primary', 'danger'],
max_actions=6,
supports_updates=True,
)
async def send_interaction(self, target_type, target_id, request, callback_token, reply_to=None):
self.token = callback_token
self.sent.set()
return InteractionHandle(message_id='card-1')
async def update_interaction(self, handle, state, submission=None):
self.updates.append(state)
return handle
class TextAdapter:
def __init__(self):
self.messages = []
self.sent = asyncio.Event()
def get_interaction_capabilities(self):
return InteractionDeliveryCapabilities()
async def send_message(self, target_type, target_id, message):
self.messages.append(str(message))
self.sent.set()
class BlockingStore:
def __init__(self):
self.record = None
self.token = 'test-token'
async def create_request(self, **data):
self.record = {
**data,
'status': 'pending',
'submission': None,
}
return self.record, self.token
async def record_delivery_success(self, run_id, interaction_id, delivery_result):
self.record['delivery_result'] = delivery_result
async def get_by_callback_token(self, token):
return self.record if token == self.token else None
async def consume_submission(self, *, callback_token, submission, bot_id, conversation_id, actor_id, **kwargs):
if self.record['status'] != 'pending':
raise ValueError('Interaction was already submitted')
if self.record['actor_id'] and self.record['actor_id'] != actor_id:
raise ValueError('Actor mismatch')
self.record['status'] = 'submitted'
self.record['submission'] = submission
return self.record
async def mark_terminal(self, run_id, interaction_id, status):
self.record['status'] = status
return True
async def mark_delivery_failed(self, run_id, interaction_id, reason):
self.record['status'] = 'delivery_failed'
return True
def _blocking_session():
return {
'run_id': 'blocking-run',
'runner_id': 'plugin:test/ApprovalRunner/default',
'runtime_context': {'deadline_at': time.time() + 300},
'authorization': {
'binding_id': 'binding-1',
'processor_type': 'agent',
'processor_id': 'agent-1',
'bot_id': 'bot-1',
'workspace_id': 'workspace-1',
'thread_id': None,
'interactions_enabled': True,
'interactions_authorized': True,
'platform_context': {
'actor': {'actor_id': 'user-1'},
'delivery': {'reply_target': {'target_type': 'group', 'target_id': 'chat-1'}},
},
},
}
@pytest.mark.asyncio
async def test_blocking_request_returns_submission_in_same_run():
adapter = BlockingAdapter()
store = BlockingStore()
manager = InteractionManager(SimpleNamespace(), store=store)
session = _blocking_session()
original_deadline = session['runtime_context']['deadline_at']
task = asyncio.create_task(
manager.request_interaction(
session=session,
adapter=adapter,
request_payload={
'interaction_id': 'approval',
'title': 'Approve?',
'actions': [
{'id': 'yes', 'label': 'Yes'},
{'id': 'no', 'label': 'No'},
],
},
timeout=1,
on_unsupported='error',
responders='actor',
)
)
await asyncio.wait_for(adapter.sent.wait(), 1)
record = await manager.consume_callback(
callback_token=adapter.token,
submission={'action_ref': 0},
bot_id='bot-1',
conversation_id='group_chat-1',
actor_id='user-1',
)
assert manager.wake_waiter(record, 'user-1')
result = await task
assert result['status'] == 'submitted'
assert result['submission']['action_id'] == 'yes'
assert adapter.updates == ['submitted']
assert original_deadline <= result['deadline_at'] < original_deadline + 1
with pytest.raises(Exception):
await manager.consume_callback(
callback_token=adapter.token,
submission={'action_ref': 1},
bot_id='bot-1',
conversation_id='group_chat-1',
actor_id='user-1',
)
@pytest.mark.asyncio
async def test_blocking_request_times_out_without_polling():
adapter = BlockingAdapter()
store = BlockingStore()
manager = InteractionManager(SimpleNamespace(), store=store)
result = await manager.request_interaction(
session=_blocking_session(),
adapter=adapter,
request_payload={
'interaction_id': 'timeout',
'title': 'Approve?',
'actions': [
{'id': 'yes', 'label': 'Yes'},
],
},
timeout=0.02,
on_unsupported='error',
responders='actor',
)
assert result['status'] == 'timeout'
assert adapter.updates == ['expired']
record = await store.get_by_callback_token(adapter.token)
assert record['status'] == 'expired'
@pytest.mark.asyncio
async def test_blocking_request_cancel_invalidates_card():
adapter = BlockingAdapter()
store = BlockingStore()
manager = InteractionManager(SimpleNamespace(), store=store)
task = asyncio.create_task(
manager.request_interaction(
session=_blocking_session(),
adapter=adapter,
request_payload={
'interaction_id': 'cancel',
'title': 'Approve?',
'actions': [
{'id': 'yes', 'label': 'Yes'},
],
},
timeout=1,
on_unsupported='error',
responders='actor',
)
)
await asyncio.wait_for(adapter.sent.wait(), 1)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert store.record['status'] == 'cancelled'
assert adapter.updates == ['cancelled']
assert not manager._waiters
@pytest.mark.asyncio
async def test_blocking_request_rejects_unlisted_responder_before_consuming():
adapter = BlockingAdapter()
store = BlockingStore()
manager = InteractionManager(SimpleNamespace(), store=store)
task = asyncio.create_task(
manager.request_interaction(
session=_blocking_session(),
adapter=adapter,
request_payload={
'interaction_id': 'restricted',
'title': 'Approve?',
'actions': [
{'id': 'yes', 'label': 'Yes'},
],
},
timeout=1,
on_unsupported='error',
responders=['user-1'],
)
)
await asyncio.wait_for(adapter.sent.wait(), 1)
with pytest.raises(ValueError, match='allowed responder'):
await manager.consume_callback(
callback_token=adapter.token,
submission={'action_ref': 0},
bot_id='bot-1',
conversation_id='group_chat-1',
actor_id='user-2',
)
assert store.record['status'] == 'pending'
record = await manager.consume_callback(
callback_token=adapter.token,
submission={'action_ref': 0},
bot_id='bot-1',
conversation_id='group_chat-1',
actor_id='user-1',
)
assert manager.wake_waiter(record, 'user-1')
assert (await task)['status'] == 'submitted'
@pytest.mark.asyncio
async def test_text_fallback_consumes_numbered_action():
adapter = TextAdapter()
store = BlockingStore()
manager = InteractionManager(SimpleNamespace(), store=store)
task = asyncio.create_task(
manager.request_interaction(
session=_blocking_session(),
adapter=adapter,
request_payload={
'interaction_id': 'text-choice',
'title': 'Approve?',
'actions': [
{'id': 'yes', 'label': 'Yes'},
{'id': 'no', 'label': 'No'},
],
},
timeout=1,
on_unsupported='degrade',
responders='actor',
)
)
await asyncio.wait_for(adapter.sent.wait(), 1)
assert '1 Yes / 2 No' in adapter.messages[0]
assert await manager.consume_text_message(
workspace_id='workspace-1',
bot_id='bot-1',
conversation_id='group_chat-1',
actor_id='user-1',
text='2',
)
result = await task
assert result['submission']['action_id'] == 'no'
assert result['degraded'][-1]['applied'] == 'text_fallback'
@pytest.mark.asyncio
async def test_unsupported_error_does_not_persist_request():
store = BlockingStore()
manager = InteractionManager(SimpleNamespace(), store=store)
result = await manager.request_interaction(
session=_blocking_session(),
adapter=TextAdapter(),
request_payload={'interaction_id': 'error', 'title': 'Approve?', 'actions': [{'id': 'yes', 'label': 'Yes'}]},
timeout=1,
on_unsupported='error',
responders='actor',
)
assert result['unsupported'][0]['path'] == 'platform'
assert store.record is None
@@ -0,0 +1,34 @@
"""Deadline behavior while a Runner blocks on a Host interaction."""
import asyncio
import time
from types import SimpleNamespace
import pytest
from langbot.pkg.agent.runner.invoker import RunnerInvoker
@pytest.mark.asyncio
async def test_next_result_survives_interaction_deadline_extension():
gate = asyncio.Event()
context = {'runtime': {'deadline_at': time.time() + 0.02}}
async def results():
await gate.wait()
yield {'type': 'run.completed'}
reader = RunnerInvoker(SimpleNamespace())
task = asyncio.create_task(
reader._next_with_deadline(
results(),
SimpleNamespace(id='test'),
context,
)
)
await asyncio.sleep(0.01)
context['runtime']['deadline_at'] = time.time() + 0.1
await asyncio.sleep(0.03)
gate.set()
assert (await task)['type'] == 'run.completed'
@@ -395,11 +395,11 @@ async def test_event_reply_uses_run_reply_stream_when_available() -> None:
@pytest.mark.asyncio
async def test_present_interactive_card_uses_host_interaction_manager() -> None:
async def test_request_interaction_uses_host_interaction_manager() -> None:
adapter = SimpleNamespace(get_supported_apis=lambda: ['interaction.request'])
manager = SimpleNamespace(
present_tool_request=AsyncMock(
return_value={'ok': True, 'status': 'pending', 'interaction_id': 'card-host-generated'}
request_interaction=AsyncMock(
return_value={'status': 'submitted', 'submission': {'interaction_id': 'card-host-generated'}}
)
)
ap = SimpleNamespace(
@@ -439,13 +439,14 @@ async def test_present_interactive_card_uses_host_interaction_manager() -> None:
},
)
assert result == {'ok': True, 'status': 'pending', 'interaction_id': 'card-host-generated'}
payload = manager.present_tool_request.await_args.kwargs['request_payload']
assert result == {'status': 'submitted', 'submission': {'interaction_id': 'card-host-generated'}}
payload = manager.request_interaction.await_args.kwargs['request_payload']
assert payload['interaction_id'].startswith('card-')
assert payload['fallback_text'].startswith('Choose deployment environment')
assert 'callback_token' not in payload
assert manager.present_tool_request.await_args.kwargs['session'] is session
assert manager.present_tool_request.await_args.kwargs['adapter'] is adapter
assert manager.request_interaction.await_args.kwargs['session'] is session
assert manager.request_interaction.await_args.kwargs['adapter'] is adapter
assert manager.request_interaction.await_args.kwargs['on_unsupported'] == 'degrade'
with pytest.raises(ValueError, match='cannot combine fields and actions'):
await execute_platform_tool(
@@ -262,11 +262,14 @@ async def test_pipeline_receives_interactive_card_tool_for_supported_adapter(app
resources = await build_resources(
app,
query,
make_descriptor(capabilities={'tool_calling': True}),
make_descriptor(
capabilities={'tool_calling': True, 'interactions': True},
permissions={**FULL_PERMISSIONS, 'interactions': ['request']},
),
)
assert [tool['tool_name'] for tool in resources['tools']] == ['present_interactive_card']
assert resources['platform_capabilities']['authorized_tools'] == ['present_interactive_card']
assert [tool['tool_name'] for tool in resources['tools']] == ['request_interaction']
assert resources['platform_capabilities']['authorized_tools'] == ['request_interaction']
@pytest.mark.asyncio
@@ -288,7 +291,7 @@ async def test_event_processor_cannot_receive_interactive_card_tool(app):
scope=BindingScope(scope_type='global'),
runner_id=RUNNER_ID,
processor_type='event_processor',
resource_policy=ResourcePolicy(allowed_platform_tool_names=['present_interactive_card']),
resource_policy=ResourcePolicy(allowed_platform_tool_names=['request_interaction']),
delivery_policy=DeliveryPolicy(enable_interactions=True),
)