mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-26 18:35:38 +08:00
Core no longer assembles a whole trace locally. Every workflow node is uploaded
the moment it completes as its own `execution_node` record, and each execution
closes with exactly one `execution_chain` record; all records of one execution
carry the same `event_id`, so Space groups them back into a single chain.
- every execution starts at an inbound event: event-less Pipeline lanes and
WebUI/API debug runs synthesize their virtual inbound event at the boundary
and are marked debug=true, so a Runner is never a trace origin
- sampling is decided once per event from a stable hash of the event id (the
previous `int(id[:8], 16)` raised on every `platform:`-prefixed id and so
dropped every successful inbound-event chain) and is reported on each record
as sample{mode,denominator}; the default mode is now `all`
- the local trace buffer, the 120s TTL, the per-process trace cap and the shared
open-node mirror are gone, which also removes the truncated-chain upload and
the re-buffered-closed-trace leak they caused
- a broken chain is always uploaded, and its reason falls back to the first
failed node so the chain explains itself
- flush keeps records until the sender accepts them and counts what it drops;
telemetry health replaces the unused send-task queue
- heartbeat reports bound Agent / plugin-processor / event-binding counts
- bound Pipeline and Agent resources (plugins, MCP servers, knowledge bases,
tools with their Host source, skills, models and the Runner schema selectors
that picked them) are reported on the chain record
- failure text is bounded and redacted instead of passing exception text through
- the dead per-query feature counters and their call sites are removed
Wire contract: schema 2 (`execution_node` / `execution_chain`).
263 lines
10 KiB
Python
263 lines
10 KiB
Python
"""Helpers for interpreting Runner DynamicForm configuration."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import typing
|
|
|
|
from .descriptor import RunnerDescriptor
|
|
|
|
|
|
FORM_ITEM_TYPE_ALIASES = {
|
|
'select-llm-model': 'llm-model-selector',
|
|
'select-knowledge-bases': 'knowledge-base-multi-selector',
|
|
}
|
|
LLM_MODEL_SELECTOR_TYPES = {'model-fallback-selector', 'llm-model-selector'}
|
|
KB_SELECTOR_TYPES = {'knowledge-base-multi-selector'}
|
|
PROMPT_EDITOR_TYPES = {'prompt-editor'}
|
|
NONE_SENTINELS = {'', '__none__', '__none'}
|
|
|
|
# Selector field types that pick a Host resource. The Runner config form is
|
|
# plugin-declared and open-ended (DynamicFormItemSchema allows extra types), so
|
|
# this is a registry, not a fixed list: a new selector type is one line here and
|
|
# is then reported by telemetry without touching the extraction call sites.
|
|
RESOURCE_SELECTOR_TYPES: dict[str, str] = {
|
|
'knowledge-base-multi-selector': 'knowledge_bases',
|
|
'tool-multi-selector': 'tools',
|
|
'mcp-server-multi-selector': 'mcp_servers',
|
|
'skill-multi-selector': 'skills',
|
|
}
|
|
|
|
|
|
def declared_resource_selectors(descriptor: RunnerDescriptor | None) -> dict[str, list[str]]:
|
|
"""Map each selected resource kind to the schema field names that select it.
|
|
|
|
Only the descriptor is read, so this reports what a Runner *can* select even
|
|
when the current configuration leaves the field empty.
|
|
"""
|
|
declared: dict[str, list[str]] = {}
|
|
if descriptor is None:
|
|
return declared
|
|
for item in descriptor.config_schema or []:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
kind = RESOURCE_SELECTOR_TYPES.get(normalize_schema_item_type(item.get('type')))
|
|
field_name = item.get('name')
|
|
if not kind or not isinstance(field_name, str) or not field_name:
|
|
continue
|
|
declared.setdefault(kind, [])
|
|
if field_name not in declared[kind]:
|
|
declared[kind].append(field_name)
|
|
return declared
|
|
|
|
|
|
def extract_selected_resources(
|
|
descriptor: RunnerDescriptor | None,
|
|
runner_config: dict[str, typing.Any] | None,
|
|
) -> dict[str, list[str]]:
|
|
"""Extract the resource ids selected through schema-declared selector fields.
|
|
|
|
Field names are never hard-coded: they come from the Runner's own config
|
|
schema, so a form that renames or adds a selector keeps working.
|
|
"""
|
|
selected: dict[str, list[str]] = {}
|
|
if not isinstance(runner_config, dict):
|
|
return selected
|
|
for kind, field_names in declared_resource_selectors(descriptor).items():
|
|
values: list[str] = []
|
|
for field_name in field_names:
|
|
value = runner_config.get(field_name)
|
|
if isinstance(value, str):
|
|
if value not in NONE_SENTINELS:
|
|
values.append(value)
|
|
elif isinstance(value, (list, tuple)):
|
|
values.extend(item for item in value if isinstance(item, str) and item not in NONE_SENTINELS)
|
|
if values:
|
|
selected[kind] = list(dict.fromkeys(values))
|
|
return selected
|
|
|
|
|
|
def normalize_schema_item_type(item_type: typing.Any) -> typing.Any:
|
|
"""Normalize legacy/frontend DynamicForm aliases to protocol field types."""
|
|
if not isinstance(item_type, str):
|
|
return item_type
|
|
return FORM_ITEM_TYPE_ALIASES.get(item_type, item_type)
|
|
|
|
|
|
def iter_schema_items(
|
|
descriptor: RunnerDescriptor | None,
|
|
field_types: set[str],
|
|
) -> typing.Iterator[dict[str, typing.Any]]:
|
|
"""Yield descriptor config schema items whose type is in field_types."""
|
|
if descriptor is None:
|
|
return
|
|
for item in descriptor.config_schema or []:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
if normalize_schema_item_type(item.get('type')) in field_types:
|
|
yield item
|
|
|
|
|
|
def uses_host_models(descriptor: RunnerDescriptor | None) -> bool:
|
|
"""Return whether LangBot should resolve model resources for this runner."""
|
|
return any(True for _ in iter_schema_items(descriptor, LLM_MODEL_SELECTOR_TYPES))
|
|
|
|
|
|
def uses_host_tools(descriptor: RunnerDescriptor | None) -> bool:
|
|
"""Return whether LangBot should expose tool resources to this runner."""
|
|
return descriptor is not None and descriptor.supports_tool_calling()
|
|
|
|
|
|
def uses_host_knowledge_bases(descriptor: RunnerDescriptor | None) -> bool:
|
|
"""Return whether LangBot should expose knowledge-base resources to this runner."""
|
|
return descriptor is not None and descriptor.supports_knowledge_retrieval()
|
|
|
|
|
|
def supports_skill_authoring(descriptor: RunnerDescriptor | None) -> bool:
|
|
"""Return whether the runner wants Host skill-authoring tools."""
|
|
if descriptor is None:
|
|
return False
|
|
return descriptor.capabilities.skill_authoring
|
|
|
|
|
|
def extract_prompt_config(
|
|
descriptor: RunnerDescriptor | None,
|
|
runner_config: dict[str, typing.Any],
|
|
default_prompt: list[dict[str, typing.Any]],
|
|
) -> list[dict[str, typing.Any]]:
|
|
"""Extract the prompt-editor value selected by the runner schema."""
|
|
for item in iter_schema_items(descriptor, PROMPT_EDITOR_TYPES):
|
|
field_name = item.get('name')
|
|
if field_name and field_name in runner_config:
|
|
configured_prompt = runner_config[field_name]
|
|
if isinstance(configured_prompt, list):
|
|
return configured_prompt
|
|
default_value = item.get('default')
|
|
if isinstance(default_value, list):
|
|
return default_value
|
|
return default_prompt
|
|
|
|
|
|
def extract_model_selection(
|
|
descriptor: RunnerDescriptor | None,
|
|
runner_config: dict[str, typing.Any],
|
|
) -> tuple[str, list[str]]:
|
|
"""Extract primary/fallback LLM selections from schema-defined fields."""
|
|
primary_uuid = ''
|
|
fallback_uuids: list[str] = []
|
|
|
|
for item in iter_schema_items(descriptor, LLM_MODEL_SELECTOR_TYPES):
|
|
field_name = item.get('name')
|
|
if not field_name:
|
|
continue
|
|
|
|
value = runner_config.get(field_name, item.get('default'))
|
|
item_type = normalize_schema_item_type(item.get('type'))
|
|
if item_type == 'model-fallback-selector':
|
|
if isinstance(value, str):
|
|
primary_uuid = value
|
|
elif isinstance(value, dict):
|
|
primary_uuid = value.get('primary') or ''
|
|
fallbacks = value.get('fallbacks', [])
|
|
if isinstance(fallbacks, list):
|
|
fallback_uuids = [fallback for fallback in fallbacks if isinstance(fallback, str)]
|
|
break
|
|
|
|
if item_type == 'llm-model-selector' and isinstance(value, str):
|
|
primary_uuid = value
|
|
break
|
|
|
|
return primary_uuid, fallback_uuids
|
|
|
|
|
|
def extract_knowledge_base_uuids(
|
|
descriptor: RunnerDescriptor | None,
|
|
runner_config: dict[str, typing.Any],
|
|
) -> list[str]:
|
|
"""Extract configured knowledge-base UUIDs from schema-defined fields."""
|
|
if not uses_host_knowledge_bases(descriptor):
|
|
return []
|
|
|
|
kb_uuids: list[str] = []
|
|
for item in iter_schema_items(descriptor, KB_SELECTOR_TYPES):
|
|
field_name = item.get('name')
|
|
if not field_name:
|
|
continue
|
|
value = runner_config.get(field_name, item.get('default', []))
|
|
if isinstance(value, list):
|
|
kb_uuids.extend(kb_uuid for kb_uuid in value if isinstance(kb_uuid, str) and kb_uuid not in NONE_SENTINELS)
|
|
|
|
return list(dict.fromkeys(kb_uuids))
|
|
|
|
|
|
def iter_config_model_refs(
|
|
descriptor: RunnerDescriptor,
|
|
runner_config: dict[str, typing.Any],
|
|
) -> typing.Iterator[tuple[str, str]]:
|
|
"""Yield model references declared by schema-defined model selector fields."""
|
|
for item in descriptor.config_schema or []:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
|
|
field_name = item.get('name')
|
|
field_type = normalize_schema_item_type(item.get('type'))
|
|
if not field_name or field_name not in runner_config:
|
|
continue
|
|
|
|
value = runner_config.get(field_name)
|
|
if field_type == 'model-fallback-selector':
|
|
if isinstance(value, str) and value not in NONE_SENTINELS:
|
|
yield 'llm', value
|
|
elif isinstance(value, dict):
|
|
primary = value.get('primary')
|
|
if isinstance(primary, str) and primary not in NONE_SENTINELS:
|
|
yield 'llm', primary
|
|
fallbacks = value.get('fallbacks', [])
|
|
if isinstance(fallbacks, list):
|
|
for fallback_uuid in fallbacks:
|
|
if isinstance(fallback_uuid, str) and fallback_uuid not in NONE_SENTINELS:
|
|
yield 'llm', fallback_uuid
|
|
elif field_type == 'llm-model-selector':
|
|
if isinstance(value, str) and value not in NONE_SENTINELS:
|
|
yield 'llm', value
|
|
elif field_type == 'rerank-model-selector':
|
|
if isinstance(value, str) and value not in NONE_SENTINELS:
|
|
yield 'rerank', value
|
|
|
|
|
|
def set_empty_llm_model_selection(
|
|
descriptor: RunnerDescriptor,
|
|
runner_config: dict[str, typing.Any],
|
|
model_uuid: str,
|
|
) -> bool:
|
|
"""Set the first empty schema-defined LLM selector to model_uuid."""
|
|
for item in iter_schema_items(descriptor, LLM_MODEL_SELECTOR_TYPES):
|
|
field_name = item.get('name')
|
|
field_type = normalize_schema_item_type(item.get('type'))
|
|
if not field_name:
|
|
continue
|
|
|
|
value = runner_config.get(field_name, item.get('default'))
|
|
if field_type == 'model-fallback-selector':
|
|
if isinstance(value, dict):
|
|
primary = value.get('primary') or ''
|
|
if primary not in NONE_SENTINELS:
|
|
return False
|
|
fallbacks = value.get('fallbacks', [])
|
|
runner_config[field_name] = {
|
|
'primary': model_uuid,
|
|
'fallbacks': fallbacks if isinstance(fallbacks, list) else [],
|
|
}
|
|
return True
|
|
if isinstance(value, str) and value not in NONE_SENTINELS:
|
|
return False
|
|
runner_config[field_name] = {'primary': model_uuid, 'fallbacks': []}
|
|
return True
|
|
|
|
if field_type == 'llm-model-selector':
|
|
if isinstance(value, str) and value not in NONE_SENTINELS:
|
|
return False
|
|
runner_config[field_name] = model_uuid
|
|
return True
|
|
|
|
return False
|