feat: add verified manual pipeline migration for plugin runners

This commit is contained in:
RockChinQ
2026-09-16 18:13:11 +00:00
parent 273b1ea3cf
commit 8c119bc4b6
97 changed files with 16662 additions and 123 deletions
@@ -249,6 +249,20 @@ class RunnerContextBuilder:
'timestamp': event.event_time or int(time.time()),
}
# A legacy Tbox resume must use the persisted original bot, not a
# newly reconstructed callback envelope. Ordinary sender mode is unchanged.
bot_id = event.bot_id
if event.event_type == 'interaction.submitted' and binding.runner_config.get('user-id-source') == 'legacy-bot':
identity = event.legacy_identity
bot_id = (
identity.bot_id
if identity is not None
and identity.workspace_id == event.workspace_id
and identity.bot_id == event.bot_id
and identity.pipeline_id == (binding.processor_id or binding.agent_id)
else None
)
# Build conversation context from event
conversation: ConversationContext | None = None
if event.conversation_id:
@@ -256,13 +270,39 @@ class RunnerContextBuilder:
'session_id': None,
'conversation_id': event.conversation_id,
'thread_id': event.thread_id,
'launcher_type': None, # Will be filled from actor/subject if needed
'launcher_type': None,
'launcher_id': None,
'sender_id': event.actor.actor_id if event.actor else None,
'bot_id': event.bot_id,
'bot_id': bot_id,
'workspace_id': event.workspace_id,
}
identity = event.legacy_identity
if (
binding.runner_config.get('user-id-source') == 'legacy-session'
and identity is not None
and identity.workspace_id == event.workspace_id
and identity.bot_id == event.bot_id
and identity.pipeline_id == (binding.processor_id or binding.agent_id)
and binding.processor_type == 'pipeline'
):
# Coze used Query itself; Dify/n8n used Query.session, which may
# deliberately differ (e.g. group per-member session isolation).
if binding.runner_id == 'plugin:langbot-team/CozeAgent/default':
launcher_type = identity.query_launcher_type
launcher_id = identity.query_launcher_id
elif binding.runner_id in {
'plugin:langbot-team/DifyAgent/default',
'plugin:langbot-team/N8nAgent/default',
}:
launcher_type = identity.session_launcher_type
launcher_id = identity.session_launcher_id
else:
launcher_type = launcher_id = None
if launcher_type in ('group', 'person') and launcher_id:
conversation['launcher_type'] = launcher_type
conversation['launcher_id'] = launcher_id
# Build event context (Protocol v1 event-first)
event_context = {
'event_id': event.event_id,
@@ -322,7 +362,7 @@ class RunnerContextBuilder:
'trace_id': run_id,
'deadline_at': self._build_deadline_from_binding(binding),
'metadata': {
'bot_id': event.bot_id,
'bot_id': bot_id,
'workspace_id': event.workspace_id,
'streaming_supported': event.delivery.supports_streaming,
'model_context_window_tokens': model_context_window_tokens,
@@ -17,6 +17,18 @@ from langbot_plugin.api.entities.builtin.runner.input import AgentInput
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
class LegacyRunnerIdentity(pydantic.BaseModel):
"""Host-captured native Query identity; never populated from event data/params."""
workspace_id: str | None = None
bot_id: str | None = None
pipeline_id: str | None = None
query_launcher_type: str | None = None
query_launcher_id: str | None = None
session_launcher_type: str | None = None
session_launcher_id: str | None = None
class AgentEventEnvelope(pydantic.BaseModel):
"""Event envelope for LangBot Host event gateway.
@@ -69,6 +81,9 @@ class AgentEventEnvelope(pydantic.BaseModel):
data: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
"""Small structured event payload. Large payloads should be referenced via raw_ref."""
legacy_identity: LegacyRunnerIdentity | None = None
"""Host-only provenance, separate from untrusted event payload and adapter params."""
# Binding scope types
class BindingScope(pydantic.BaseModel):
@@ -4,6 +4,7 @@ from __future__ import annotations
from langbot.pkg.telemetry import diagnostics
import copy
import json
import time
import typing
@@ -14,7 +15,7 @@ from langbot_plugin.api.entities.builtin.platform import message as platform_mes
from .descriptor import RunnerDescriptor
from .errors import RunnerProtocolError
from .host_models import AgentBinding, AgentEventEnvelope
from .host_models import AgentBinding, AgentEventEnvelope, LegacyRunnerIdentity
from .interaction_store import InteractionStore
@@ -152,7 +153,12 @@ class InteractionManager:
runner_id=descriptor.id,
processor_type=binding.processor_type,
processor_id=processor_id,
request=request_data,
request={
**request_data,
'_host_legacy_identity': event.legacy_identity.model_dump(mode='json')
if event.legacy_identity is not None
else None,
},
delivery_target=reply_target,
replaces_interaction_id=(update_target or {}).get('interaction_id'),
bot_id=event.bot_id,
@@ -161,6 +167,7 @@ class InteractionManager:
thread_id=event.thread_id,
actor_id=event.actor.actor_id if event.actor else None,
expires_at=expires_at,
**self._pipeline_admission(binding, descriptor, adapter_context),
)
try:
@@ -204,6 +211,50 @@ class InteractionManager:
diagnostics.set_outcome('waiting', reason_code='waiting')
return True
@staticmethod
def _pipeline_admission(binding, descriptor, adapter_context):
if binding.processor_type != 'pipeline':
return {}
query = (adapter_context or {}).get('_query')
expected = copy.deepcopy((adapter_context or {}).get('_pipeline_expected_config'))
session = getattr(query, 'session', None)
conversation = (adapter_context or {}).get('_pipeline_conversation')
def authority():
from .config_resolver import RunnerConfigResolver
return (
query is not None
and session is not None
and conversation is not None
and isinstance(expected, dict)
and session.using_conversation is conversation
and getattr(query, 'pipeline_uuid', None) == binding.processor_id
and RunnerConfigResolver.resolve_runner_id(expected) == descriptor.id == binding.runner_id
and RunnerConfigResolver.resolve_runner_config(expected, descriptor.id) == binding.runner_config
)
return {'expected_config': expected, 'authority_check': authority}
async def restore_legacy_identity(self, event: AgentEventEnvelope, binding: AgentBinding) -> None:
"""Resume only from the scoped, consumed Host record, never callback fields."""
event.legacy_identity = None
submission = getattr(event.input, 'interaction', None)
if submission is None:
return
if hasattr(submission, 'model_dump'):
submission = submission.model_dump(mode='json')
record = await self.store.find_resume_request(event=event, binding=binding, submission=submission)
if record is None:
return
identity = record['request'].get('_host_legacy_identity')
if not isinstance(identity, dict):
return
try:
event.legacy_identity = LegacyRunnerIdentity.model_validate(identity)
except pydantic.ValidationError:
return
@diagnostics.observe('interaction', 'interaction.acknowledge', source='platform', stage='ack')
async def acknowledge_submission(self, record: dict[str, typing.Any], adapter: typing.Any) -> None:
"""Best-effort transition of submitted controls into a read-only state."""
@@ -14,6 +14,8 @@ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from sqlalchemy.orm import sessionmaker
from ...entity.persistence.agent_interaction import AgentInteraction
from ...persistence.tenant_uow import TenantUnitOfWork
from ...persistence.pipeline_admission import lock_pipeline_admission
UTC = datetime.timezone.utc
@@ -49,6 +51,12 @@ def _utc_now() -> datetime.datetime:
return datetime.datetime.now(UTC)
def _db_datetime(value):
# Existing schema is TIMESTAMP WITHOUT TIME ZONE; persist UTC naive on
# both backends and normalize to aware UTC only for application comparisons.
return _as_utc(value).replace(tzinfo=None) if value is not None else None
def _as_utc(value: datetime.datetime | None) -> datetime.datetime | None:
if value is None:
return None
@@ -113,6 +121,8 @@ class InteractionStore:
thread_id: str | None = None,
actor_id: str | None = None,
expires_at: int | float | None = None,
expected_config: dict | None = None,
authority_check: typing.Callable[[], bool] | None = None,
) -> 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:
@@ -145,11 +155,32 @@ class InteractionStore:
delivery_target_json=delivery_target_json,
replaces_interaction_id=replaces_interaction_id,
callback_token_hash=_token_hash(callback_token),
expires_at=expires_at_dt,
created_at=now,
updated_at=now,
expires_at=_db_datetime(expires_at_dt),
created_at=_db_datetime(now),
updated_at=_db_datetime(now),
)
if processor_type == 'pipeline':
# Require Host-captured configuration and live conversation authority;
# runner output must never supply either of these admission inputs.
if not workspace_id or not isinstance(expected_config, dict) or authority_check is None:
raise InteractionScopeError('Pipeline interaction authority unavailable')
try:
async with TenantUnitOfWork(self.engine, workspace_id) as uow:
current = await lock_pipeline_admission(uow.session, workspace_id, processor_id)
if current != expected_config or authority_check() is not True:
raise InteractionScopeError('Pipeline interaction authority changed')
existing = await self._get_by_run_interaction(uow.session, run_id, interaction_id)
if existing is not None:
raise DuplicateInteractionError('Interaction already exists')
uow.session.add(row)
await uow.session.flush()
return self._to_dict(row), callback_token
except ValueError as exc:
raise InteractionScopeError('Pipeline interaction authority unavailable') from exc
except IntegrityError as exc:
raise DuplicateInteractionError('Interaction already exists') from exc
async with self._session_factory() as session:
existing = await self._get_by_run_interaction(session, run_id, interaction_id)
if existing is not None:
@@ -182,7 +213,7 @@ class InteractionStore:
AgentInteraction.interaction_id == interaction_id,
AgentInteraction.status.in_(['pending', 'submitted']),
)
.values(delivery_result_json=payload, updated_at=_utc_now())
.values(delivery_result_json=payload, updated_at=_db_datetime(_utc_now()))
)
await session.commit()
return result.rowcount == 1
@@ -228,6 +259,38 @@ class InteractionStore:
row = result.scalar_one_or_none()
return self._to_dict(row) if row is not None else None
async def find_resume_request(self, *, event, binding, submission) -> dict[str, typing.Any] | None:
"""Resolve persisted provenance with exact tenant/processor/callback scope.
Ambiguous identities fail closed instead of picking the newest run.
"""
if not isinstance(submission, dict) or not submission.get('interaction_id'):
return None
target = event.delivery.reply_target or {}
target_type, target_id = target.get('target_type'), target.get('target_id')
conversation_id = f'{target_type}_{target_id}' if target_type and target_id else event.conversation_id
conditions = [
AgentInteraction.interaction_id == submission['interaction_id'],
AgentInteraction.status == 'submitted',
AgentInteraction.binding_id == binding.binding_id,
AgentInteraction.runner_id == binding.runner_id,
AgentInteraction.processor_type == binding.processor_type,
AgentInteraction.processor_id == (binding.processor_id or binding.agent_id),
]
for column, value in (
(AgentInteraction.workspace_id, event.workspace_id),
(AgentInteraction.bot_id, event.bot_id),
(AgentInteraction.conversation_id, conversation_id),
(AgentInteraction.thread_id, event.thread_id),
(AgentInteraction.actor_id, event.actor.actor_id if event.actor else None),
):
conditions.append(column.is_(None) if value is None else column == value)
async with self._session_factory() as session:
result = await session.execute(sqlalchemy.select(AgentInteraction).where(*conditions))
records = [self._to_dict(row) for row in result.scalars()]
matches = [record for record in records if record['submission'] == submission]
return matches[0] if len(matches) == 1 else None
async def get_request(self, run_id: str, interaction_id: str) -> dict[str, typing.Any] | None:
"""Return a request by the runner-visible identity."""
async with self._session_factory() as session:
@@ -283,7 +346,7 @@ class InteractionStore:
if expires_at is not None and expires_at <= now:
row.status = 'expired'
row.status_reason = 'interaction expired before submission'
row.updated_at = now
row.updated_at = _db_datetime(now)
await session.commit()
raise InteractionExpiredError('Interaction has expired')
@@ -297,9 +360,9 @@ class InteractionStore:
)
.values(
status='submitted',
submitted_at=submission_time,
submitted_at=_db_datetime(submission_time),
submission_json=_json_dumps(submission),
updated_at=now,
updated_at=_db_datetime(now),
)
)
if update_result.rowcount != 1:
@@ -323,7 +386,7 @@ class InteractionStore:
AgentInteraction.interaction_id == interaction_id,
AgentInteraction.status == 'pending',
)
.values(status='delivery_failed', status_reason=reason, updated_at=now)
.values(status='delivery_failed', status_reason=reason, updated_at=_db_datetime(now))
)
await session.commit()
return result.rowcount == 1
@@ -337,12 +400,12 @@ class InteractionStore:
.where(
AgentInteraction.status == 'pending',
AgentInteraction.expires_at.is_not(None),
AgentInteraction.expires_at <= cutoff,
AgentInteraction.expires_at <= _db_datetime(cutoff),
)
.values(
status='expired',
status_reason='interaction expired',
updated_at=cutoff,
updated_at=_db_datetime(cutoff),
)
)
await session.commit()
@@ -0,0 +1,85 @@
"""Host-owned, run-scoped reasoning policy for schema-declared model selectors.
This policy is not an SDK resource or a provider kwarg. Only the Host binding
assembler supplies it; model actions consult it after resource authorization.
"""
from __future__ import annotations
import copy
import typing
from ...provider.modelmgr.reasoning import normalize_reasoning_config, validate_reasoning_config
from .config_schema import NONE_SENTINELS, iter_schema_items
from .descriptor import RunnerDescriptor
if typing.TYPE_CHECKING:
from ...provider.modelmgr.requester import RuntimeLLMModel
ModelReasoningOverrides = dict[str, dict[str, str]]
def extract_model_reasoning_overrides(
descriptor: RunnerDescriptor,
runner_config: dict[str, typing.Any],
resources: typing.Mapping[str, typing.Any],
) -> ModelReasoningOverrides:
"""Normalize explicit UUID-to-level mappings, intersecting selection and grants.
An absent entry preserves persisted model defaults. Explicit provider_default
overrides them using the native requester's semantics. Descriptor defaults and
undeclared config fields cannot silently introduce reasoning overrides.
"""
authorized = {model.get('model_id') for model in resources.get('models', [])}
overrides: ModelReasoningOverrides = {}
for item in iter_schema_items(descriptor, {'model-fallback-selector'}):
field_name = item.get('name')
if not isinstance(field_name, str):
continue
selection = runner_config.get(field_name)
if not isinstance(selection, dict) or 'reasoning' not in selection:
continue
configured = selection['reasoning']
if not isinstance(configured, dict):
raise ValueError('Invalid runner model reasoning configuration')
candidates = [selection.get('primary')]
fallbacks = selection.get('fallbacks')
if isinstance(fallbacks, list):
candidates.extend(fallbacks)
selected = {value for value in candidates if isinstance(value, str) and value not in NONE_SENTINELS}
for model_id, level in configured.items():
# Validate with Core, but do not echo arbitrary config/secret values.
try:
if not isinstance(model_id, str) or not isinstance(level, str):
raise ValueError
config = normalize_reasoning_config({'level': level})
except (TypeError, ValueError):
raise ValueError('Invalid runner model reasoning configuration') from None
if model_id not in selected or model_id not in authorized:
continue
if model_id in overrides and overrides[model_id] != config:
raise ValueError('Conflicting runner model reasoning overrides')
overrides[model_id] = config
return overrides
def model_with_reasoning_override(
model: RuntimeLLMModel,
model_id: str,
session: typing.Mapping[str, typing.Any] | None,
) -> RuntimeLLMModel:
"""Clone only the runtime wrapper, after the caller has authorized the model.
Do not mutate shared model entities or providers. Requesters retain ownership
of ability/capability validation and provider-specific argument translation.
"""
if session is None:
return model
overrides = session.get('authorization', {}).get('model_reasoning_overrides', {})
if model_id not in overrides:
return model
config = validate_reasoning_config(overrides[model_id], model.model_entity.abilities, model.model_entity.extra_args)
scoped_model = copy.copy(model)
scoped_model.reasoning_config_override = copy.deepcopy(config)
return scoped_model
@@ -29,6 +29,7 @@ from .execution_context import (
)
from .host_models import AgentBinding, AgentEventEnvelope
from .invoker import RunnerInvoker
from .model_reasoning import extract_model_reasoning_overrides
from .interaction_manager import InteractionManager
from .query_bridge import QueryRunBridge
from .registry import RunnerRegistry
@@ -121,6 +122,12 @@ class AgentRunOrchestrator:
project_mcp_resource_config(execution_query, binding.runner_config)
object.__setattr__(execution_query, '_execution_context', execution_context)
if event.event_type == 'interaction.submitted' and binding.runner_config.get('user-id-source') in (
'legacy-session',
'legacy-bot',
):
await self.interaction_manager.restore_legacy_identity(event, binding)
execution_event = event
resource_addition = await build_mcp_resource_context_addition(self.ap, execution_query)
if resource_addition:
@@ -133,6 +140,7 @@ class AgentRunOrchestrator:
binding=binding,
descriptor=descriptor,
)
model_reasoning_overrides = extract_model_reasoning_overrides(descriptor, binding.runner_config, resources)
context = await self.context_builder.build_context_from_event(
event=execution_event,
@@ -192,6 +200,7 @@ class AgentRunOrchestrator:
'state_scopes': list(binding.state_policy.state_scopes),
},
'state_context': state_context,
'model_reasoning_overrides': model_reasoning_overrides,
}
seen_sequences: set[int] = set()
@@ -228,6 +237,7 @@ class AgentRunOrchestrator:
execution_query=execution_query,
platform_context=freeze_platform_context(event),
reply_streams=reply_streams,
model_reasoning_overrides=model_reasoning_overrides,
)
event_log_id = await self.journal.write_event_log(
@@ -412,6 +422,10 @@ class AgentRunOrchestrator:
plan = self.query_bridge.build_plan(query)
adapter_context = dict(plan.adapter_context)
adapter_context['_query'] = query
import copy
adapter_context['_pipeline_expected_config'] = copy.deepcopy(query.pipeline_config)
adapter_context['_pipeline_conversation'] = getattr(getattr(query, 'session', None), 'using_conversation', None)
adapter_context['_execution_context'] = get_query_execution_context(query)
# Inbound files and subsequent runner tools must share one Host scope.
@@ -25,6 +25,7 @@ from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
from .host_models import (
AgentConfig,
AgentEventEnvelope,
LegacyRunnerIdentity,
StatePolicy,
DeliveryPolicy,
)
@@ -100,6 +101,24 @@ class QueryEntryAdapter:
delivery=delivery,
raw_ref=raw_ref,
data=event.data,
legacy_identity=cls._build_legacy_identity(query, execution_context.workspace_uuid),
)
@staticmethod
def _build_legacy_identity(query, workspace_id: str) -> LegacyRunnerIdentity:
def exact(value):
value = getattr(value, 'value', value)
return value if isinstance(value, str) and value else None
session = getattr(query, 'session', None)
return LegacyRunnerIdentity(
workspace_id=workspace_id,
bot_id=exact(getattr(query, 'bot_uuid', None)),
pipeline_id=exact(getattr(query, 'pipeline_uuid', None)),
query_launcher_type=exact(getattr(query, 'launcher_type', None)),
query_launcher_id=exact(getattr(query, 'launcher_id', None)),
session_launcher_type=exact(getattr(session, 'launcher_type', None)),
session_launcher_id=exact(getattr(session, 'launcher_id', None)),
)
@classmethod
@@ -13,6 +13,7 @@ import threading
from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query
from .context_builder import AgentResources
from .model_reasoning import ModelReasoningOverrides
from ...provider.tools.toolmgr import ToolSourceRef
@@ -52,6 +53,7 @@ class RunAuthorizationSnapshot(typing.TypedDict):
platform_context: dict[str, typing.Any]
authorized_ids: dict[str, set[str]]
authorized_operations: dict[str, dict[str, set[str]]]
model_reasoning_overrides: ModelReasoningOverrides
SteeringQueueItem = dict[str, typing.Any]
@@ -119,6 +121,7 @@ class AgentRunSessionRegistry:
execution_query: pipeline_query.Query | None = None,
platform_context: dict[str, typing.Any] | None = None,
reply_streams: typing.Any = None,
model_reasoning_overrides: ModelReasoningOverrides | None = None,
) -> None:
"""Register a new agent run session.
@@ -164,6 +167,7 @@ class AgentRunSessionRegistry:
'platform_context': copy.deepcopy(platform_context or {}),
'authorized_ids': self._build_authorized_ids(resources_snapshot),
'authorized_operations': self._build_authorized_operations(resources_snapshot),
'model_reasoning_overrides': copy.deepcopy(model_reasoning_overrides or {}),
}
session: AgentRunSession = {