GitHub user rosemarYuan edited a discussion: [Discussion] Trigger @Action by 
Event Field Values

# \[Discussion\] Trigger action by Event Field Values

**Search before asking**

*   [x] I searched in the 
[issues](https://github.com/apache/flink-agents/issues) and found nothing 
similar.
    

**Summary**

Currently, an `Action` is triggered by matching the incoming event type. It is 
too coarse-grained when an action should only run for a subset of events based 
on event fields. This issue extends `@Action` from event-type matching to 
field-based conditional matching. Users can now declare trigger condition 
expressions directly on the `Action`.

# Motivation

In Agent workflows, Action routing often depends on the **content of an 
event**, not only on its event type. A typical example is `ChatResponseEvent`, 
which represents the result of an LLM call. The same event type can originate 
from different upstream Actions and carry different structured outputs, and 
downstream Actions usually want to handle only a specific subset.

For example, a workflow may call the LLM from both a planner Action and a 
writer Action. Both calls emit `ChatResponseEvent`, but they should be routed 
differently: planner responses should go to planner logic, while writer 
responses may be further split into month-plan or day-plan handlers based on a 
field such as `response.plantype`.

```plaintext
PlannerAction  ->  LLM call #1  ->  ChatResponseEvent

WriterAction   ->  LLM call #2  ->  ChatResponseEvent
                                      response.plantype = "Month" or contains 
"Day"
```

Today, the framework only sees all of these handlers as listeners of 
`ChatResponseEvent`. Without condition-based triggers, every handler is invoked 
for every `ChatResponseEvent`, and each method must repeat its own `if-return` 
guard logic to check both metadata and payload fields.

This splits routing rules between the `@Action` declaration and the method 
body. It reduces usability, because users must inspect implementation code to 
understand routing behavior, and reduces observability, because the framework 
only sees coarse-grained event-type routing.

The proposed direction is to make Action routing conditions declarative and 
framework-visible, so both metadata conditions and content conditions can be 
expressed directly in the `@Action` declaration while the method body stays 
focused on business logic:

```java
// Metadata only: fires on the planner's response.
@Action("type == EventType.ChatResponseEvent && request_sender == 
'PlannerAction'")
public static void handlePlannerResponse(Event event, RunnerContext ctx) {
    // ... planner-response handling
}

// Metadata + content: fires only on writer-generated month plans.
@Action("type == EventType.ChatResponseEvent && request_sender == 
'WriterAction' && response.plantype == 'Month'")
public static void handleWriterMonthPlan(Event event, RunnerContext ctx) {
    // ... month-plan handling
}

// Metadata + content: fires on writer responses whose plantype contains "Day".
@Action("type == EventType.ChatResponseEvent && request_sender == 
'WriterAction' && response.plantype.contains('Day')")
public static void handleWriterDayPlan(Event event, RunnerContext ctx) {
    // ... day-plan handling
}
```
> Note: `request_sender` and `response.plantype` are illustrative fields. 
> `request_sender` is not currently carried by `ChatResponseEvent`; propagating 
> it from request to response can be introduced as an orthogonal enhancement. 

# Goals

This change aims to make `@Action` triggers more expressive while keeping the 
existing model simple and compatible. Specifically, it supports two 
capabilities:

1.  Provide a user-friendly trigger expression model. 
    
2.  Support triggering actions with both simple type matching and field-based 
conditions.
    

Rollout: a stack of independently reviewable PRs; breakdown tracked in the 
follow-up umbrella issue.

## Expression Language Choice

This design uses CEL as the trigger condition language. Several alternatives 
were considered.

| Option | Pros | Cons |
| --- | --- | --- |
| Custom parser | Full control over syntax. | High maintenance cost; 
Java/Python consistency is hard. |
| Java/Python callback | Very flexible. | Not declarative or easily 
serializable; hard to validate, trace, cache, and optimize. |
| Annotation predicates | Structured and easy to validate. | Verbose and poor 
at compound boolean logic. |
| CEL expression | Compact, declarative, language-neutral, and 
runtime-friendly. | Needs a clearly documented supported subset. |

CEL is selected because trigger conditions should be declarative, 
side-effect-free, serializable, and centrally evaluable by the framework. It 
also provides a better fit for Java/Python consistency than executable callback 
predicates.

# API

The `Action` annotation declares a list of one or more **triggers**, each of 
which is either an **event type name** (e.g. `EventType.InputEvent`, shorthand 
for `type == <that event type>`) **or** a **CEL boolean expression** (e.g. 
`"type == EventType.ChatResponseEvent"`). 

In Java, multiple triggers must be enclosed in`{}`, a single trigger does not 
need braces. Python passes triggers as ordinary positional arguments.

### Example

**Java** 

```java
// Direct event-type triggers. Entries are OR'ed.
@Action({EventType.InputEvent, EventType.OutputEvent})

// Metadata filter.
@Action("type == EventType.ChatResponseEvent && request_sender == 
'PlannerAction'")

// Mixed triggers. Entries are OR'ed.
@Action({
    EventType.InputEvent,                                                       
    // direct type
    "type == EventType.ChatResponseEvent && retryCount > 0",                    
    // metadata condition
    "type == EventType.ToolResponseEvent && response.success == false",         
    // payload condition
    "type == EventType.ChatResponseEvent && response.plantype.contains('Day')"  
    // metadata + payload
})
```

**Python**

```python
# Direct event-type triggers. Entries are OR'ed.
@action(EventType.InputEvent, EventType.OutputEvent)

# Metadata filter.
@action("type == EventType.ChatResponseEvent && request_sender == 
'PlannerAction'")

# Mixed triggers. Entries are OR'ed.
@action(
    EventType.InputEvent,                                                       
     # direct type
    "type == EventType.ChatResponseEvent && retryCount > 0",                    
     # metadata
    "type == EventType.ToolResponseEvent && response.success == false",         
     # payload
    "type == EventType.ChatResponseEvent && response.plantype.contains('Day')"  
     # metadata + payload
)
```

### Notes

*   Multiple triggers combine with **OR** — the action fires when any one of 
them matches.
    
*   Inside an expression, reference event fields by their **bare names** — the 
framework automatically rewrites them under `attributes` (e.g. `retryCount` → 
`attributes.retryCount`, `response.structured_output` → 
`attributes.response.structured_output`). 
    
*   The root identifiers `id` and `type` are **reserved** for the 
framework-owned event id and event type. If a business field would otherwise be 
named `id` or `type`, rename it (e.g. `orderId`, `requestId`).
    
*   `EventType.XxxEvent` is a compile-time shortcut for the canonical 
event-type string of a **built-in** event (e.g. `EventType.InputEvent`). For 
non-built-in events, write the fully-qualified event-type string directly in 
the trigger expression (e.g. `"type == 'myEvent'"`).
    

### Supported expression Syntax

The trigger condition uses a constrained CEL-style expression subset. The same 
expression syntax can be used for event metadata and payload fields, subject to 
CEL type-checking rules:

| Form | Examples |
| --- | --- |
| Comparison | `==`, `!=`, `<`, `<=`, `>`, `>=` |
| Boolean composition | `&&`, `\|\|`, `!` |
| Membership | `status in ['FAILED', 'TIMEOUT']` |
| Membership Field presence | `has(TypeId)` |
| Nested field access | `response.structured_output` |
| String functions | 
`name.contains('foo')`,`name.startsWith('order_')`,`name.matches('^o_\d+$')` |
| Explicit attribute access | `attributes['retryCount'] > 0` |

This issue does not propose exposing the full CEL language surface in this 
version. Phase 1 scopes a small subset for common event-field routing: only 
`has()` is enabled, while comprehension macros such as `exists`, `exists_one`, 
`all`, `filter`, and `map` are disabled to keep evaluation simple and 
predictable. 

Trigger expressions are checked in stages: syntax errors are caught in 
AgentPlan; field names and value types are not checked statically — an unknown 
field or a type mismatch only surfaces when an event arrives.

The supported operators, built-ins, macros, and known edge cases are documented 
in the appendix, full operator semantics follow the [CEL language 
definition](https://github.com/google/cel-spec/blob/master/doc/langdef.md).

# Appendix  CEL Usage Rules

### 1 Comparison Operators

Comparisons require type compatibility. Cross-type conversion should be 
explicit where necessary, except that CEL supports comparisons among numeric 
types such as `int`, `uint`, and `double`.

*   `A == A -> bool`, `A != A -> bool`: equality and inequality.
    
*   `<`, `<=`, `>`, `>=`: support `bool`, `int`, `uint`, `double`, `string`, 
and `bytes`.
    
*   `<` and `<=` also support `google.protobuf.Timestamp` and 
`google.protobuf.Duration`.
    

### 2 Logical Operators

*   `bool && bool -> bool`: logical AND. Errors and unknown values do not 
necessarily halt evaluation immediately.
    
*   `bool || bool -> bool`: logical OR. Errors and unknown values do not 
necessarily halt evaluation immediately.
    

### 3 `**in**` Operator

List membership:

```text
A in list(A) -> bool
```

The time complexity is proportional to list length.

Examples:

```text
2 in [1, 2, 3]      -> true
"a" in ["b", "c"]   -> false
```

Map key presence:

```text
A in map(A, B) -> bool
```

Expected complexity is `O(1)`.

Example:

```text
'key1' in {'key1': 'v1'} -> true
```

### 4 `**has(...)**` Macro

```text
has(message.field) -> bool
```

`has(...)` checks whether a field exists. It supports proto2, proto3, and map 
key access, with map access supported through select notation.

For `attributes` as a map:

```text
has(attributes.score)
```

is equivalent to:

```text
'score' in attributes
```

The latter is also a standard CEL style and may be preferred for map key checks.

### 5 Advanced Macros Deferred

The following advanced macros are not included in the initial scope:

```text
exists(...)
exists_one(...)
all(...)
map(...)
filter(...)
```

They can be gradually enabled after the safety model and Java/Python 
consistency are validated.

GitHub link: https://github.com/apache/flink-agents/discussions/726

----
This is an automatically sent email for [email protected].
To unsubscribe, please send an email to: [email protected]

Reply via email to