mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-12 08:46:02 +00:00
refactor(agent-runner): make agent binding and auth snapshot explicit
This commit is contained in:
@@ -16,7 +16,13 @@ from .resource_builder import AgentResourceBuilder
|
||||
from .result_normalizer import AgentResultNormalizer
|
||||
from .orchestrator import AgentRunOrchestrator
|
||||
from .config_migration import ConfigMigration
|
||||
from .session_registry import AgentRunSessionRegistry, AgentRunSession, get_session_registry
|
||||
from .binding_resolver import AgentBindingResolver, AgentBindingResolutionError
|
||||
from .session_registry import (
|
||||
AgentRunSessionRegistry,
|
||||
AgentRunSession,
|
||||
RunAuthorizationSnapshot,
|
||||
get_session_registry,
|
||||
)
|
||||
from .events import (
|
||||
MESSAGE_RECEIVED,
|
||||
MESSAGE_RECALLED,
|
||||
@@ -41,8 +47,11 @@ __all__ = [
|
||||
'AgentResultNormalizer',
|
||||
'AgentRunOrchestrator',
|
||||
'ConfigMigration',
|
||||
'AgentBindingResolver',
|
||||
'AgentBindingResolutionError',
|
||||
'AgentRunSessionRegistry',
|
||||
'AgentRunSession',
|
||||
'RunAuthorizationSnapshot',
|
||||
'get_session_registry',
|
||||
'MESSAGE_RECEIVED',
|
||||
'MESSAGE_RECALLED',
|
||||
|
||||
63
src/langbot/pkg/agent/runner/binding_resolver.py
Normal file
63
src/langbot/pkg/agent/runner/binding_resolver.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Resolve host events to one effective Agent binding."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .host_models import AgentConfig, AgentBinding, AgentEventEnvelope, BindingScope
|
||||
|
||||
|
||||
class AgentBindingResolutionError(Exception):
|
||||
"""Raised when an event cannot resolve to exactly one Agent binding."""
|
||||
|
||||
|
||||
class AgentBindingResolver:
|
||||
"""Resolve an event to a single AgentBinding.
|
||||
|
||||
The target product model is one bot / IM channel -> one Agent. Fan-out,
|
||||
observer agents, or multi-runner arbitration require separate delivery and
|
||||
state semantics and are intentionally not hidden in this resolver.
|
||||
"""
|
||||
|
||||
def resolve_one(
|
||||
self,
|
||||
event: AgentEventEnvelope,
|
||||
agents: list[AgentConfig],
|
||||
) -> AgentBinding:
|
||||
"""Resolve exactly one enabled Agent for the event."""
|
||||
matches = [
|
||||
agent
|
||||
for agent in agents
|
||||
if agent.enabled and event.event_type in agent.event_types
|
||||
]
|
||||
|
||||
if not matches:
|
||||
raise AgentBindingResolutionError(
|
||||
f'No Agent binding matches event_type={event.event_type}'
|
||||
)
|
||||
|
||||
if len(matches) > 1:
|
||||
agent_ids = ', '.join(agent.agent_id or '<anonymous>' for agent in matches)
|
||||
raise AgentBindingResolutionError(
|
||||
f'Multiple Agent bindings match event_type={event.event_type}: {agent_ids}'
|
||||
)
|
||||
|
||||
return self._to_binding(matches[0])
|
||||
|
||||
def _to_binding(self, agent: AgentConfig) -> AgentBinding:
|
||||
"""Project product-level Agent config into the run-time binding model."""
|
||||
scope = BindingScope(
|
||||
scope_type='agent',
|
||||
scope_id=agent.agent_id,
|
||||
)
|
||||
|
||||
return AgentBinding(
|
||||
binding_id=f"agent_{agent.agent_id or 'default'}_{agent.runner_id}",
|
||||
scope=scope,
|
||||
event_types=list(agent.event_types),
|
||||
runner_id=agent.runner_id,
|
||||
runner_config=agent.runner_config,
|
||||
resource_policy=agent.resource_policy,
|
||||
state_policy=agent.state_policy,
|
||||
delivery_policy=agent.delivery_policy,
|
||||
enabled=agent.enabled,
|
||||
agent_id=agent.agent_id,
|
||||
)
|
||||
@@ -133,6 +133,42 @@ class DeliveryPolicy(pydantic.BaseModel):
|
||||
"""Maximum message size."""
|
||||
|
||||
|
||||
class AgentConfig(pydantic.BaseModel):
|
||||
"""Host-side Agent configuration.
|
||||
|
||||
Product-level Agent is the target replacement for Pipeline-owned agent
|
||||
config. Current Pipeline entry paths can project their config into this
|
||||
model during migration.
|
||||
"""
|
||||
|
||||
agent_id: str | None = None
|
||||
"""Host-side Agent/config identifier."""
|
||||
|
||||
runner_id: str
|
||||
"""Runner ID to invoke."""
|
||||
|
||||
runner_config: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
|
||||
"""Agent/runner binding configuration."""
|
||||
|
||||
resource_policy: ResourcePolicy = pydantic.Field(default_factory=ResourcePolicy)
|
||||
"""Resource policy for this Agent."""
|
||||
|
||||
state_policy: StatePolicy = pydantic.Field(default_factory=StatePolicy)
|
||||
"""State policy for this Agent."""
|
||||
|
||||
delivery_policy: DeliveryPolicy = pydantic.Field(default_factory=DeliveryPolicy)
|
||||
"""Delivery policy for this Agent."""
|
||||
|
||||
event_types: list[str] = pydantic.Field(default_factory=lambda: ["message.received"])
|
||||
"""Event types this Agent handles."""
|
||||
|
||||
enabled: bool = True
|
||||
"""Whether this Agent can be selected by a binding resolver."""
|
||||
|
||||
metadata: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
|
||||
"""Non-protocol diagnostic metadata, such as legacy config source."""
|
||||
|
||||
|
||||
class AgentBinding(pydantic.BaseModel):
|
||||
"""Binding configuration for mapping events to runners.
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from .session_registry import get_session_registry, AgentRunSessionRegistry
|
||||
from .config_migration import ConfigMigration
|
||||
from .host_models import AgentEventEnvelope, AgentBinding
|
||||
from .query_entry_adapter import QueryEntryAdapter
|
||||
from .binding_resolver import AgentBindingResolver
|
||||
from .state_scope import build_state_context
|
||||
from .errors import (
|
||||
RunnerNotFoundError,
|
||||
@@ -61,6 +62,8 @@ class AgentRunOrchestrator:
|
||||
|
||||
result_normalizer: AgentResultNormalizer
|
||||
|
||||
binding_resolver: AgentBindingResolver
|
||||
|
||||
# Cached singleton references (set in __init__)
|
||||
_session_registry: AgentRunSessionRegistry
|
||||
_persistent_state_store: PersistentStateStore | None
|
||||
@@ -75,6 +78,7 @@ class AgentRunOrchestrator:
|
||||
self.context_builder = AgentRunContextBuilder(ap)
|
||||
self.resource_builder = AgentResourceBuilder(ap)
|
||||
self.result_normalizer = AgentResultNormalizer(ap)
|
||||
self.binding_resolver = AgentBindingResolver()
|
||||
# Cache singleton references to avoid per-request getter calls
|
||||
self._session_registry = get_session_registry()
|
||||
self._persistent_state_store = None # Lazy init on first use
|
||||
@@ -258,8 +262,10 @@ class AgentRunOrchestrator:
|
||||
# Convert Query to event-first envelope
|
||||
event = QueryEntryAdapter.query_to_event(query)
|
||||
|
||||
# Convert current config to binding
|
||||
binding = QueryEntryAdapter.config_to_binding(query, runner_id)
|
||||
# Project legacy Pipeline config into target Agent config, then resolve
|
||||
# exactly one effective binding for this event.
|
||||
agent_config = QueryEntryAdapter.config_to_agent_config(query, runner_id)
|
||||
binding = self.binding_resolver.resolve_one(event, [agent_config])
|
||||
|
||||
# Extract bound plugins for authorization
|
||||
bound_plugins = query.variables.get('_pipeline_bound_plugins')
|
||||
|
||||
@@ -21,9 +21,8 @@ from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
|
||||
|
||||
from .host_models import (
|
||||
AgentConfig,
|
||||
AgentEventEnvelope,
|
||||
AgentBinding,
|
||||
BindingScope,
|
||||
ResourcePolicy,
|
||||
StatePolicy,
|
||||
DeliveryPolicy,
|
||||
@@ -36,7 +35,7 @@ class QueryEntryAdapter:
|
||||
|
||||
This adapter is responsible for:
|
||||
- Converting Query to AgentEventEnvelope
|
||||
- Converting current Agent/runner config to temporary AgentBinding
|
||||
- Projecting current Pipeline config to temporary AgentConfig
|
||||
- Putting Query-only fields into adapter context
|
||||
"""
|
||||
|
||||
@@ -97,30 +96,17 @@ class QueryEntryAdapter:
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def config_to_binding(
|
||||
def config_to_agent_config(
|
||||
cls,
|
||||
query: pipeline_query.Query,
|
||||
runner_id: str,
|
||||
) -> AgentBinding:
|
||||
"""Convert current config container to temporary AgentBinding.
|
||||
|
||||
Args:
|
||||
query: Current entry query
|
||||
runner_id: Resolved runner ID
|
||||
|
||||
Returns:
|
||||
AgentBinding for this run
|
||||
"""
|
||||
) -> AgentConfig:
|
||||
"""Project the current Pipeline config container into target Agent config."""
|
||||
pipeline_config = query.pipeline_config or {}
|
||||
ai_config = pipeline_config.get('ai', {})
|
||||
runner_config = ai_config.get('runner_config', {}).get(runner_id, {})
|
||||
agent_id = getattr(query, 'pipeline_uuid', None)
|
||||
|
||||
scope = BindingScope(
|
||||
scope_type="agent",
|
||||
scope_id=agent_id,
|
||||
)
|
||||
|
||||
# Build resource policy from current config
|
||||
resource_policy = ResourcePolicy(
|
||||
allowed_model_uuids=cls._extract_allowed_models(query),
|
||||
@@ -140,17 +126,16 @@ class QueryEntryAdapter:
|
||||
enable_reply=True,
|
||||
)
|
||||
|
||||
return AgentBinding(
|
||||
binding_id=f"agent_{agent_id or 'default'}_{runner_id}",
|
||||
scope=scope,
|
||||
event_types=[runner_events.MESSAGE_RECEIVED],
|
||||
return AgentConfig(
|
||||
agent_id=agent_id,
|
||||
runner_id=runner_id,
|
||||
runner_config=runner_config,
|
||||
resource_policy=resource_policy,
|
||||
state_policy=state_policy,
|
||||
delivery_policy=delivery_policy,
|
||||
event_types=[runner_events.MESSAGE_RECEIVED],
|
||||
enabled=True,
|
||||
agent_id=agent_id,
|
||||
metadata={'source': 'pipeline_adapter'},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import typing
|
||||
import time
|
||||
import threading
|
||||
@@ -15,6 +16,22 @@ class AgentRunSessionStatus(typing.TypedDict):
|
||||
last_activity_at: int
|
||||
|
||||
|
||||
class RunAuthorizationSnapshot(typing.TypedDict):
|
||||
"""Frozen authorization data for one active run.
|
||||
|
||||
ResourceBuilder creates the authorized resource list once before runner
|
||||
execution. Runtime proxy handlers must validate against this run-scoped
|
||||
snapshot instead of recomputing resource policy.
|
||||
"""
|
||||
|
||||
resources: AgentResources
|
||||
permissions: dict[str, list[str]]
|
||||
conversation_id: str | None
|
||||
state_policy: dict[str, typing.Any]
|
||||
state_context: dict[str, typing.Any]
|
||||
authorized_ids: dict[str, set[str]]
|
||||
|
||||
|
||||
class AgentRunSession(typing.TypedDict):
|
||||
"""Session for an active agent runner execution.
|
||||
|
||||
@@ -25,25 +42,15 @@ class AgentRunSession(typing.TypedDict):
|
||||
runner_id: Runner descriptor ID (plugin:author/name/runner)
|
||||
query_id: Host entry query ID, only present for query-based adapters
|
||||
plugin_identity: Plugin identifier (author/name) of the runner
|
||||
conversation_id: Conversation ID for history/event access
|
||||
resources: Authorized resources for this run (from AgentResources)
|
||||
permissions: Runner permissions from descriptor (artifacts, history, events, etc.)
|
||||
state_policy: State policy from binding (enable_state, state_scopes)
|
||||
state_context: Context for state API (scope_keys, binding_identity, etc.)
|
||||
authorization: Run-scoped authorization snapshot; runtime auth truth
|
||||
status: Session status tracking
|
||||
_authorized_ids: Pre-computed authorized resource IDs for O(1) lookup
|
||||
"""
|
||||
run_id: str
|
||||
runner_id: str
|
||||
query_id: int | None
|
||||
plugin_identity: str # author/name
|
||||
conversation_id: str | None
|
||||
resources: AgentResources
|
||||
permissions: dict[str, list[str]]
|
||||
state_policy: dict[str, typing.Any] # {enable_state: bool, state_scopes: list}
|
||||
state_context: dict[str, typing.Any] # {scope_keys: dict, binding_identity: str, ...}
|
||||
authorization: RunAuthorizationSnapshot
|
||||
status: AgentRunSessionStatus
|
||||
_authorized_ids: dict[str, set[str]] # Pre-computed sets for O(1) lookup
|
||||
|
||||
|
||||
class AgentRunSessionRegistry:
|
||||
@@ -82,7 +89,7 @@ class AgentRunSessionRegistry:
|
||||
Args:
|
||||
run_id: Unique run identifier
|
||||
runner_id: Runner descriptor ID
|
||||
query_id: Host entry query ID, only present for query-based adapters
|
||||
query_id: Host entry query ID, only present for query-based adapters
|
||||
plugin_identity: Plugin identifier (author/name)
|
||||
resources: Authorized resources for this run
|
||||
conversation_id: Conversation ID for history/event access
|
||||
@@ -102,36 +109,40 @@ class AgentRunSessionRegistry:
|
||||
# Normalize state_context to empty dict if None
|
||||
state_context = state_context or {}
|
||||
|
||||
# Pre-compute authorized resource IDs for O(1) lookup
|
||||
authorized_ids: dict[str, set[str]] = {
|
||||
'model': {m.get('model_id') for m in resources.get('models', [])},
|
||||
'tool': {t.get('tool_name') for t in resources.get('tools', [])},
|
||||
'knowledge_base': {kb.get('kb_id') for kb in resources.get('knowledge_bases', [])},
|
||||
'file': {f.get('file_id') for f in resources.get('files', [])},
|
||||
resources_snapshot = copy.deepcopy(resources)
|
||||
authorization: RunAuthorizationSnapshot = {
|
||||
'resources': resources_snapshot,
|
||||
'permissions': copy.deepcopy(permissions),
|
||||
'conversation_id': conversation_id,
|
||||
'state_policy': copy.deepcopy(state_policy),
|
||||
'state_context': copy.deepcopy(state_context),
|
||||
'authorized_ids': self._build_authorized_ids(resources_snapshot),
|
||||
}
|
||||
|
||||
# NOTE: state_policy and state_context are stored at session top-level,
|
||||
# NOT in resources. Resources should only contain resource authorization info.
|
||||
session: AgentRunSession = {
|
||||
'run_id': run_id,
|
||||
'runner_id': runner_id,
|
||||
'query_id': query_id,
|
||||
'plugin_identity': plugin_identity,
|
||||
'conversation_id': conversation_id,
|
||||
'resources': resources, # Original AgentResources, no state metadata mixed in
|
||||
'permissions': permissions,
|
||||
'state_policy': state_policy,
|
||||
'state_context': state_context,
|
||||
'authorization': authorization,
|
||||
'status': {
|
||||
'started_at': now,
|
||||
'last_activity_at': now,
|
||||
},
|
||||
'_authorized_ids': authorized_ids,
|
||||
}
|
||||
|
||||
async with self._lock:
|
||||
self._sessions[run_id] = session
|
||||
|
||||
def _build_authorized_ids(self, resources: AgentResources) -> dict[str, set[str]]:
|
||||
"""Pre-compute authorized resource IDs for O(1) lookup."""
|
||||
return {
|
||||
'model': {m.get('model_id') for m in resources.get('models', [])},
|
||||
'tool': {t.get('tool_name') for t in resources.get('tools', [])},
|
||||
'knowledge_base': {kb.get('kb_id') for kb in resources.get('knowledge_bases', [])},
|
||||
'file': {f.get('file_id') for f in resources.get('files', [])},
|
||||
}
|
||||
|
||||
async def unregister(self, run_id: str) -> None:
|
||||
"""Unregister an agent run session.
|
||||
|
||||
@@ -182,13 +193,15 @@ class AgentRunSessionRegistry:
|
||||
Returns:
|
||||
True if resource is authorized, False otherwise
|
||||
"""
|
||||
authorized_ids = session.get('_authorized_ids', {})
|
||||
authorization = session['authorization']
|
||||
authorized_ids = authorization['authorized_ids']
|
||||
resources = authorization['resources']
|
||||
|
||||
if resource_type in ('model', 'tool', 'knowledge_base', 'file'):
|
||||
return resource_id in authorized_ids.get(resource_type, set())
|
||||
|
||||
if resource_type == 'storage':
|
||||
storage = session['resources'].get('storage', {})
|
||||
storage = resources.get('storage', {})
|
||||
if resource_id == 'plugin':
|
||||
return storage.get('plugin_storage', False)
|
||||
elif resource_id == 'workspace':
|
||||
|
||||
Reference in New Issue
Block a user