Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
c2aa568
Merge branch 'OpenBMB:main' into main
LaansDole Feb 8, 2026
4b948a5
feat: add Jinja2 template edge processor
LaansDole Feb 8, 2026
f6cda3c
chores: refactor
LaansDole Feb 8, 2026
b17d687
feat: template demo
LaansDole Feb 8, 2026
ea55189
refactor
LaansDole Feb 8, 2026
9ae2aec
refactor
LaansDole Feb 8, 2026
686b059
refactor
LaansDole Feb 8, 2026
cbc9ab3
refactor
LaansDole Feb 8, 2026
642e625
chores: uv lock
LaansDole Feb 9, 2026
dd03d83
Merge branch 'main' into feature/add-template-edge-processor
LaansDole Feb 9, 2026
3b60179
feat: add template node for Jinja2-based formatting
LaansDole Feb 9, 2026
7255661
fix: call text_content() method in template node executor
LaansDole Feb 9, 2026
acf0efd
fix: replace StatusReport agent with template node
LaansDole Feb 9, 2026
f64ae20
refactor: remove decorative headers from template formats
LaansDole Feb 9, 2026
8385dfa
docs: update tasks checklist to reflect completed documentation
LaansDole Feb 9, 2026
79181ba
feat: demo YAML for node template
LaansDole Feb 10, 2026
69c1c68
Merge branch 'OpenBMB:main' into main
LaansDole Feb 10, 2026
c359845
feat: implement node template
LaansDole Feb 10, 2026
0ea37ec
feat: add template node for Jinja2-based formatting
LaansDole Feb 9, 2026
bda859d
fix: call text_content() method in template node executor
LaansDole Feb 9, 2026
4af4c19
fix: replace StatusReport agent with template node
LaansDole Feb 9, 2026
2f3f3ef
refactor: remove decorative headers from template formats
LaansDole Feb 9, 2026
1488d92
docs: update tasks checklist to reflect completed documentation
LaansDole Feb 9, 2026
906a17b
chores: refactor template demo
LaansDole Feb 10, 2026
7d16e18
chore: merge main
LaansDole Feb 10, 2026
cd65c68
fix: template demo YAML file
LaansDole Feb 10, 2026
78b2222
chores: remove template demo
LaansDole Feb 10, 2026
6ee14f5
Delete openspec/changes/add-template-formatter-node directory
LaansDole Feb 10, 2026
b251478
Delete openspec/changes/add-template-formatter-node directory
LaansDole Feb 10, 2026
d24da47
Merge branch 'OpenBMB:main' into main
LaansDole Feb 11, 2026
36d0617
chores: merge upstream
LaansDole Feb 12, 2026
40cbdbd
feat: rich tooltip component
LaansDole Feb 8, 2026
bb7e8d2
feat: add help content to tooltip
LaansDole Feb 8, 2026
bdcc61f
feat: wrapping nodes with tooltip content
LaansDole Feb 8, 2026
fef91d5
feat: scroll to content in tutorial view
LaansDole Feb 8, 2026
c62ed0d
chores: refactor
LaansDole Feb 8, 2026
177c9f1
feat: no tooltip shown on custom node
LaansDole Feb 10, 2026
15bb548
feat: move enable help tooltips default true to setting
LaansDole Feb 10, 2026
5946ae4
Add .worktrees/ to .gitignore for git worktree support
LaansDole Feb 7, 2026
bc5f72c
feat: Add loop_timer node for time-based loop control
LaansDole Feb 7, 2026
05ca01c
refactor: Merge loop_timer demos and extend duration
LaansDole Feb 7, 2026
9a3ea4a
fix: Correct YAML format in demo_loop_timer.yaml
LaansDole Feb 7, 2026
91e6611
fix: Correct loop structure in demo_loop_timer.yaml
LaansDole Feb 7, 2026
bde1f76
fix: loop timer demo
LaansDole Feb 8, 2026
58cffa2
feat: finalize demo loop timer
LaansDole Feb 8, 2026
ef6d98a
feat: loop_timer node docs
LaansDole Feb 8, 2026
c8db6da
chores: refactor
LaansDole Feb 8, 2026
c65a08c
feat: move duration as enum option
LaansDole Feb 10, 2026
fa28bdc
chores: merge main
LaansDole Feb 12, 2026
f6a5e7e
Merge branch 'OpenBMB:main' into feature/add-template-edge-processor
LaansDole Feb 19, 2026
e568b88
Merge branch 'OpenBMB:main' into feature/add-template-edge-processor
LaansDole Feb 26, 2026
801a0e3
Merge branch 'OpenBMB:main' into feature/add-template-edge-processor
LaansDole Mar 2, 2026
b980d9f
Delete yaml_instance/demo_template.yaml
LaansDole Mar 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,6 @@ node_modules/
data/
temp/
WareHouse/

# Git worktrees
.worktrees/
85 changes: 70 additions & 15 deletions entity/configs/edge/edge_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,6 @@ def to_external_value(self) -> Any:
return _serialize_config(self)




_NO_MATCH_DESCRIPTIONS = {
"pass": "Leave the payload untouched when no match is found.",
"default": "Apply default_value (or empty string) if nothing matches.",
Expand Down Expand Up @@ -117,7 +115,6 @@ class RegexEdgeProcessorConfig(EdgeProcessorTypeConfig):
description="Whether to collect all matches instead of only the first.",
advance=True,
),

"template": ConfigFieldSpec(
name="template",
display_name="Output Template",
Expand Down Expand Up @@ -152,7 +149,9 @@ class RegexEdgeProcessorConfig(EdgeProcessorTypeConfig):
}

@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "RegexEdgeProcessorConfig":
def from_dict(
cls, data: Mapping[str, Any], *, path: str
) -> "RegexEdgeProcessorConfig":
mapping = require_mapping(data, path)
pattern = require_str(mapping, "pattern", path, allow_empty=False)
group_value = mapping.get("group")
Expand All @@ -166,14 +165,19 @@ def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "RegexEdgeProcessor
else:
group_normalized = group_value
else:
raise ConfigError("group must be str or int", extend_path(path, "group"))
raise ConfigError(
"group must be str or int", extend_path(path, "group")
)
multiple = optional_bool(mapping, "multiple", path, default=False)
case_sensitive = optional_bool(mapping, "case_sensitive", path, default=True)
multiline = optional_bool(mapping, "multiline", path, default=False)
dotall = optional_bool(mapping, "dotall", path, default=False)
on_no_match = optional_str(mapping, "on_no_match", path) or "pass"
if on_no_match not in {"pass", "default", "drop"}:
raise ConfigError("on_no_match must be pass, default or drop", extend_path(path, "on_no_match"))
raise ConfigError(
"on_no_match must be pass, default or drop",
extend_path(path, "on_no_match"),
)

template = optional_str(mapping, "template", path)
default_value = optional_str(mapping, "default_value", path)
Expand Down Expand Up @@ -230,18 +234,26 @@ def field_specs(cls) -> Dict[str, ConfigFieldSpec]:
descriptions = {}
for name in names:
meta = metadata.get(name)
descriptions[name] = (meta.description if meta else None) or "No description provided."
descriptions[name] = (
meta.description if meta else None
) or "No description provided."

specs["name"] = replace(
name_spec,
enum=names or None,
enum_options=enum_options_from_values(names, descriptions, preserve_label_case=True) if names else None,
enum_options=enum_options_from_values(
names, descriptions, preserve_label_case=True
)
if names
else None,
description=description,
)
return specs

@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "FunctionEdgeProcessorConfig":
def from_dict(
cls, data: Mapping[str, Any], *, path: str
) -> "FunctionEdgeProcessorConfig":
mapping = require_mapping(data, path)
name = require_str(mapping, "name", path, allow_empty=False)
return cls(name=name, path=path)
Expand All @@ -253,6 +265,37 @@ def to_external_value(self) -> Any:
return {"name": self.name}


@dataclass
class TemplateEdgeProcessorConfig(EdgeProcessorTypeConfig):
"""Configuration for Jinja2 template-based payload processors."""

template: str = ""

FIELD_SPECS = {
"template": ConfigFieldSpec(
name="template",
display_name="Template String",
type_hint="str",
required=True,
description="Jinja2 template string for transforming edge payloads.",
)
}

@classmethod
def from_dict(
cls, data: Mapping[str, Any], *, path: str
) -> "TemplateEdgeProcessorConfig":
mapping = require_mapping(data, path)
template = require_str(mapping, "template", path, allow_empty=False)
return cls(template=template, path=path)

def display_label(self) -> str:
return "template"

def to_external_value(self) -> Any:
return {"template": self.template}


TProcessorConfig = TypeVar("TProcessorConfig", bound=EdgeProcessorTypeConfig)


Expand Down Expand Up @@ -288,12 +331,18 @@ def from_dict(cls, data: Any, *, path: str) -> "EdgeProcessorConfig":
processor_type = require_str(mapping, "type", path)
config_payload = mapping.get("config")
if config_payload is None:
raise ConfigError("processor config is required", extend_path(path, "config"))
raise ConfigError(
"processor config is required", extend_path(path, "config")
)
try:
schema = get_edge_processor_schema(processor_type)
except SchemaLookupError as exc:
raise ConfigError(f"unknown processor type '{processor_type}'", extend_path(path, "type")) from exc
processor_config = schema.config_cls.from_dict(config_payload, path=extend_path(path, "config"))
raise ConfigError(
f"unknown processor type '{processor_type}'", extend_path(path, "type")
) from exc
processor_config = schema.config_cls.from_dict(
config_payload, path=extend_path(path, "config")
)
return cls(type=processor_type, config=processor_config, path=path)

@classmethod
Expand All @@ -310,11 +359,15 @@ def field_specs(cls) -> Dict[str, ConfigFieldSpec]:
if type_spec:
registrations = iter_edge_processor_schemas()
names = list(registrations.keys())
descriptions = {name: schema.summary for name, schema in registrations.items()}
descriptions = {
name: schema.summary for name, schema in registrations.items()
}
specs["type"] = replace(
type_spec,
enum=names,
enum_options=enum_options_from_values(names, descriptions, preserve_label_case=True),
enum_options=enum_options_from_values(
names, descriptions, preserve_label_case=True
),
)
return specs

Expand All @@ -327,7 +380,9 @@ def to_external_value(self) -> Any:
"config": self.config.to_external_value(),
}

def as_config(self, expected_type: Type[TProcessorConfig]) -> TProcessorConfig | None:
def as_config(
self, expected_type: Type[TProcessorConfig]
) -> TProcessorConfig | None:
config = self.config
if isinstance(config, expected_type):
return cast(TProcessorConfig, config)
Expand Down
42 changes: 42 additions & 0 deletions entity/configs/node/template.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Configuration for template nodes."""

from dataclasses import dataclass
from typing import Mapping, Any

from entity.configs.base import (
BaseConfig,
ConfigError,
ConfigFieldSpec,
require_mapping,
require_str,
)


@dataclass
class TemplateNodeConfig(BaseConfig):
"""Config describing the Jinja2 template used to format output."""

template: str = ""

@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "TemplateNodeConfig":
mapping = require_mapping(data, path)
template = require_str(mapping, "template", path)
if not template:
raise ConfigError("template cannot be empty", f"{path}.template")

return cls(template=template, path=path)

def validate(self) -> None:
if not self.template:
raise ConfigError("template cannot be empty", f"{self.path}.template")

FIELD_SPECS = {
"template": ConfigFieldSpec(
name="template",
display_name="Jinja2 Template",
type_hint="text",
required=True,
description="Jinja2 template string for formatting output. Available context: {{ input }} (latest message content), {{ environment }} (execution environment variables).",
),
}
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ dependencies = [
"filelock>=3.20.1",
"markdown>=3.10",
"xhtml2pdf>=0.2.17",
"jinja2>=3.1.0",
]

[build-system]
Expand Down
9 changes: 9 additions & 0 deletions runtime/edge/processors/builtin_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
from entity.configs.edge.edge_processor import (
RegexEdgeProcessorConfig,
FunctionEdgeProcessorConfig,
TemplateEdgeProcessorConfig,
)
from .registry import register_edge_processor
from .regex_processor import RegexEdgePayloadProcessor
from .function_processor import FunctionEdgePayloadProcessor
from .template_processor import TemplateEdgePayloadProcessor

register_edge_processor(
"regex_extract",
Expand All @@ -21,3 +23,10 @@
summary="Delegate message transformation to Python functions in functions/edge_processor.",
config_cls=FunctionEdgeProcessorConfig,
)

register_edge_processor(
"template",
processor_cls=TemplateEdgePayloadProcessor,
summary="Transform payloads using Jinja2 templates.",
config_cls=TemplateEdgeProcessorConfig,
)
99 changes: 99 additions & 0 deletions runtime/edge/processors/template_processor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""Jinja2 template-based edge payload processor."""

import json
from typing import Any

from jinja2 import Environment, TemplateSyntaxError, UndefinedError, StrictUndefined
from jinja2.sandbox import SandboxedEnvironment

from entity.configs.edge.edge_processor import TemplateEdgeProcessorConfig
from entity.messages import Message
from runtime.node.executor import ExecutionContext
from utils.log_manager import LogManager

from .base import EdgePayloadProcessor, ProcessorFactoryContext


class TemplateRenderError(Exception):
"""Raised when template rendering fails."""

pass


def _fromjson_filter(value: str) -> Any:
"""Parse JSON string into Python object."""
try:
return json.loads(value)
except json.JSONDecodeError as exc:
raise TemplateRenderError(f"JSON decode error: {exc}") from exc


def _tojson_filter(value: Any) -> str:
"""Serialize Python object to JSON string."""
try:
return json.dumps(value, ensure_ascii=False)
except (TypeError, ValueError) as exc:
raise TemplateRenderError(f"JSON encode error: {exc}") from exc


class TemplateEdgePayloadProcessor(EdgePayloadProcessor[TemplateEdgeProcessorConfig]):
"""Transform edge payloads using Jinja2 templates."""

def __init__(
self, config: TemplateEdgeProcessorConfig, ctx: ProcessorFactoryContext
) -> None:
super().__init__(config, ctx)

# Create sandboxed Jinja2 environment
self.env = SandboxedEnvironment(
autoescape=False,
undefined=StrictUndefined, # Strict mode - fail on undefined variables
)

# Register custom filters
self.env.filters["fromjson"] = _fromjson_filter
self.env.filters["tojson"] = _tojson_filter

# Compile template during initialization to catch syntax errors early
try:
self.template = self.env.from_string(config.template)
except TemplateSyntaxError as exc:
raise TemplateRenderError(f"Invalid template syntax: {exc}") from exc

def transform(
self,
payload: Message,
*,
source_result: Message,
from_node: Any,
edge_link: Any,
log_manager: LogManager,
context: ExecutionContext,
) -> Message | None:
"""Render template with payload and context variables."""
input_text = self._text(payload)

# Build template context
template_context = {
"input": input_text,
"environment": getattr(context, "environment", {}),
"extracted": input_text, # Default to input if no prior extraction
}

# Render template
try:
output = self.template.render(template_context)
except UndefinedError as exc:
available_keys = ", ".join(sorted(template_context.keys()))
raise TemplateRenderError(
f"Undefined variable in template: {exc}. Available context keys: {available_keys}"
) from exc
except TemplateRenderError:
raise
except Exception as exc:
raise TemplateRenderError(f"Template rendering failed: {exc}") from exc

# Return new message with rendered output
cloned = payload.clone()
cloned.content = output
return cloned
10 changes: 10 additions & 0 deletions runtime/node/builtin_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from entity.configs.node.python_runner import PythonRunnerConfig
from entity.configs.node.loop_counter import LoopCounterConfig
from entity.configs.node.loop_timer import LoopTimerConfig
from entity.configs.node.template import TemplateNodeConfig
from runtime.node.executor.agent_executor import AgentNodeExecutor
from runtime.node.executor.human_executor import HumanNodeExecutor
from runtime.node.executor.passthrough_executor import PassthroughNodeExecutor
Expand All @@ -21,6 +22,7 @@
from runtime.node.executor.subgraph_executor import SubgraphNodeExecutor
from runtime.node.executor.loop_counter_executor import LoopCounterNodeExecutor
from runtime.node.executor.loop_timer_executor import LoopTimerNodeExecutor
from runtime.node.executor.template_executor import TemplateNodeExecutor
from runtime.node.registry import NodeCapabilities, register_node_type


Expand Down Expand Up @@ -100,6 +102,14 @@
summary="Blocks downstream edges until the configured time limit is reached, then emits a message to release the loop.",
)

register_node_type(
"template",
config_cls=TemplateNodeConfig,
executor_cls=TemplateNodeExecutor,
capabilities=NodeCapabilities(),
summary="Blocks downstream edges until the configured time limit is reached, then emits a message to release the loop.",
)

# Register subgraph source types (file-based and inline config)
register_subgraph_source(
"config",
Expand Down
Loading
Loading