This commit is contained in:
Quanzheng Long
2026-03-18 11:41:05 -07:00
parent 12ea4c24d5
commit c0bd2dbc07
10 changed files with 505 additions and 61 deletions
+77 -3
View File
@@ -98,13 +98,13 @@ type Context struct {
resumeEvent *WaitEvent
}
func (c *Context) WaitFor(cond AnyOfCondition) (WaitEvent, error) {
func (c *Context) WaitFor(cond AnyOfCondition) (WaitForResult, error) {
if c.resumeEvent != nil {
event := *c.resumeEvent
c.resumeEvent = nil
return event, nil
return waitForResultFromRaw(cond, event), nil
}
return WaitEvent{}, ErrWaitRequested{Condition: cond}
return WaitForResult{}, ErrWaitRequested{Condition: cond}
}
func (c *Context) PublishToChannel(channel string, value any) error {
@@ -390,3 +390,77 @@ func unwrapResumeInput(input any) (any, *WaitEvent) {
}
return rawArg, &event
}
func waitForResultFromRaw(cond AnyOfCondition, event WaitEvent) WaitForResult {
result := WaitForResult{
Conditions: make([]ConditionResult, len(cond.Conditions)),
}
if event.Condition == "timer" {
for i, raw := range cond.Conditions {
if kind, _ := raw["kind"].(string); kind == "timer" {
result.Conditions[i] = ConditionResult{Met: true}
break
}
}
return result
}
if event.Condition != "channel" {
return result
}
if event.Channel == "__any_of__" {
var matched []struct {
Channel string `json:"channel"`
Value any `json:"value"`
}
_ = json.Unmarshal(event.Value, &matched)
cursor := 0
for i, raw := range cond.Conditions {
kind, _ := raw["kind"].(string)
if kind != "channel" || cursor >= len(matched) {
continue
}
channelName, _ := raw["channel"].(string)
if channelName == matched[cursor].Channel {
result.Conditions[i] = ConditionResult{
Met: true,
ChannelName: channelName,
Values: toValues(matched[cursor].Value),
}
cursor++
}
}
return result
}
var value any
_ = json.Unmarshal(event.Value, &value)
for i, raw := range cond.Conditions {
kind, _ := raw["kind"].(string)
if kind != "channel" {
continue
}
channelName, _ := raw["channel"].(string)
if channelName == event.Channel {
result.Conditions[i] = ConditionResult{
Met: true,
ChannelName: channelName,
Values: toValues(value),
}
break
}
}
return result
}
func toValues(value any) []any {
if value == nil {
return []any{}
}
if vals, ok := value.([]any); ok {
return vals
}
return []any{value}
}
+10
View File
@@ -58,6 +58,16 @@ type WaitEvent struct {
Seconds float64 `json:"seconds,omitempty"`
}
type ConditionResult struct {
Met bool `json:"met"`
ChannelName string `json:"channel_name,omitempty"`
Values []any `json:"values,omitempty"`
}
type WaitForResult struct {
Conditions []ConditionResult `json:"conditions"`
}
type Send struct {
Node any
NodeInput any
+12 -3
View File
@@ -37,14 +37,23 @@ func (c *Context) Interrupt(name string) (any, error) {
}
return nil, err
}
if len(event.Value) == 0 {
if len(event.Conditions) == 0 || !event.Conditions[0].Met {
return nil, nil
}
values := event.Conditions[0].Values
if len(values) == 0 {
return nil, nil
}
rawValue := values[0]
valueBytes, err := json.Marshal(rawValue)
if err != nil {
return nil, fmt.Errorf("encode interrupt `%s` value: %w", name, err)
}
var payload interruptPayload
if err := json.Unmarshal(event.Value, &payload); err != nil {
if err := json.Unmarshal(valueBytes, &payload); err != nil {
var value any
if err := json.Unmarshal(event.Value, &value); err != nil {
if err := json.Unmarshal(valueBytes, &value); err != nil {
return nil, fmt.Errorf("decode interrupt `%s` value: %w", name, err)
}
return value, nil
+18 -9
View File
@@ -75,16 +75,25 @@ func (w *lunchWorkflow) waitNode(ctx *ag.Context, _ any, state lunchState) (ag.C
}
output := append([]string(nil), state.Output...)
if event.Condition == "channel" {
payload := ag.DecodeString(event.Value)
switch event.Channel {
case "tool_completion_channel":
output = append(output, "tool: "+payload)
case "subagent_completion_channel":
output = append(output, "sub_agent: "+payload)
case "user_input_channel":
output = append(output, "user_input: "+payload)
hadChannel := false
for _, cond := range event.Conditions {
if !cond.Met || cond.ChannelName == "" {
continue
}
for _, raw := range cond.Values {
payload, _ := raw.(string)
switch cond.ChannelName {
case "tool_completion_channel":
output = append(output, "tool: "+payload)
case "subagent_completion_channel":
output = append(output, "sub_agent: "+payload)
case "user_input_channel":
output = append(output, "user_input: "+payload)
}
}
hadChannel = true
}
if hadChannel {
state.Output = output
return ag.Command{Goto: []ag.Send{{Node: w.llmNode}}, Update: state}, nil
}
+105 -5
View File
@@ -1,7 +1,6 @@
package tests
import (
"encoding/json"
"fmt"
"testing"
@@ -143,7 +142,7 @@ func (w *primitiveWorkflow) startWaitBatchNode(ctx *ag.Context, _ any, state pri
}
func (w *primitiveWorkflow) waitBatchNode(ctx *ag.Context, _ any, state primitiveState) (ag.Command, error) {
event, err := ctx.WaitFor(ag.AnyOf(ag.ChannelCondition{
result, err := ctx.WaitFor(ag.AnyOf(ag.ChannelCondition{
Channel: "events",
Min: 2,
Max: 4,
@@ -151,9 +150,13 @@ func (w *primitiveWorkflow) waitBatchNode(ctx *ag.Context, _ any, state primitiv
if err != nil {
return ag.Command{}, err
}
var values []string
if err := json.Unmarshal(event.Value, &values); err != nil {
return ag.Command{}, err
if len(result.Conditions) != 1 || !result.Conditions[0].Met {
return ag.Command{}, fmt.Errorf("expected one met condition")
}
values := make([]string, 0, len(result.Conditions[0].Values))
for _, v := range result.Conditions[0].Values {
s, _ := v.(string)
values = append(values, s)
}
state.Count = len(values)
state.Logs = values
@@ -191,3 +194,100 @@ func TestChannelWaitRespectsMaxM(t *testing.T) {
t.Fatalf("unexpected done: %v", result.Done)
}
}
func (w *primitiveWorkflow) startAnyOfTwoChannelsNode(ctx *ag.Context, _ any, state primitiveState) (ag.Command, error) {
if err := ctx.PublishToChannel("alpha", "a1"); err != nil {
return ag.Command{}, err
}
if err := ctx.PublishToChannel("beta", "b1"); err != nil {
return ag.Command{}, err
}
return ag.Command{
Update: state,
Goto: []ag.Send{
{Node: w.waitAnyOfTwoChannelsNode, NodeInput: nil},
},
}, nil
}
func (w *primitiveWorkflow) waitAnyOfTwoChannelsNode(ctx *ag.Context, _ any, state primitiveState) (ag.Command, error) {
first, err := ctx.WaitFor(ag.AnyOf(
ag.ChannelCondition{Channel: "alpha"},
ag.ChannelCondition{Channel: "beta"},
))
if err != nil {
return ag.Command{}, err
}
if len(first.Conditions) != 2 {
return ag.Command{}, fmt.Errorf("expected 2 condition results, got %d", len(first.Conditions))
}
if !first.Conditions[0].Met || first.Conditions[0].ChannelName != "alpha" || len(first.Conditions[0].Values) != 1 || first.Conditions[0].Values[0] != "a1" {
return ag.Command{}, fmt.Errorf("unexpected first condition result: %#v", first.Conditions[0])
}
if !first.Conditions[1].Met || first.Conditions[1].ChannelName != "beta" || len(first.Conditions[1].Values) != 1 || first.Conditions[1].Values[0] != "b1" {
return ag.Command{}, fmt.Errorf("unexpected second condition result: %#v", first.Conditions[1])
}
if err := ctx.PublishToChannel("beta", "b2"); err != nil {
return ag.Command{}, err
}
state.Count = 1
state.Logs = []string{
"matched=2",
}
return ag.Command{
Update: state,
Goto: []ag.Send{
{Node: w.verifyBetaAfterAnyOfNode, NodeInput: nil},
},
}, nil
}
func (w *primitiveWorkflow) verifyBetaAfterAnyOfNode(ctx *ag.Context, _ any, state primitiveState) (ag.Command, error) {
second, err := ctx.WaitFor(ag.AnyOf(
ag.ChannelCondition{Channel: "beta"},
))
if err != nil {
return ag.Command{}, err
}
if len(second.Conditions) != 1 || !second.Conditions[0].Met || second.Conditions[0].ChannelName != "beta" || len(second.Conditions[0].Values) != 1 {
return ag.Command{}, fmt.Errorf("unexpected beta condition result: %#v", second.Conditions)
}
payload, _ := second.Conditions[0].Values[0].(string)
state.Count = 2
state.Logs = append(state.Logs, fmt.Sprintf("beta=%s", payload))
state.Done = "ok"
return ag.Command{Update: state}, nil
}
func TestAnyOfConsumesAllReadyChannels(t *testing.T) {
workflow := &primitiveWorkflow{}
graph := ag.NewAdvancedStateGraph[primitiveState]()
graph.AddAsyncChannel("alpha")
graph.AddAsyncChannel("beta")
graph.AddEntryNode(workflow.startAnyOfTwoChannelsNode)
graph.AddNode(workflow.waitAnyOfTwoChannelsNode)
graph.AddFinishNode(workflow.verifyBetaAfterAnyOfNode)
handler, err := graph.Compile().Start(nil, primitiveState{
Count: 0,
Logs: []string{},
Done: "",
})
if err != nil {
t.Fatalf("start failed: %v", err)
}
result, err := handler.WaitForResult()
if err != nil {
t.Fatalf("result failed: %v", err)
}
if result.Count != 2 {
t.Fatalf("unexpected count: %v", result.Count)
}
if len(result.Logs) != 2 || result.Logs[0] != "matched=2" || result.Logs[1] != "beta=b2" {
t.Fatalf("unexpected logs: %#v", result.Logs)
}
if result.Done != "ok" {
t.Fatalf("unexpected done: %v", result.Done)
}
}
+86 -12
View File
@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use serde_json::json;
use serde_json::Value;
use std::collections::{HashMap, VecDeque};
use std::env;
@@ -432,18 +433,8 @@ impl Engine {
}
loop {
for cond in &any_of.conditions {
if let WaitCondition::Channel { channel, min, max } = cond {
if *min < 1 {
return Err("channel condition min must be >= 1".to_string());
}
if *max != 0 && *max < *min {
return Err("channel condition max must be 0 or >= min".to_string());
}
if let Some(event) = self.try_take_channel_event(channel, *min, *max)? {
return Ok(event);
}
}
if let Some(event) = self.try_take_any_of_channel_events(any_of)? {
return Ok(event);
}
if let Some(seconds) = min_timer {
@@ -507,6 +498,89 @@ impl Engine {
value: serde_json::Value::Array(values),
}))
}
fn try_take_any_of_channel_events(
&self,
any_of: &AnyOfCondition,
) -> Result<Option<WaitEvent>, String> {
let mut channels = self.channels.lock().expect("channels mutex poisoned");
let mut consumed_per_channel: HashMap<String, usize> = HashMap::new();
let mut plans: Vec<(String, usize)> = Vec::new();
for cond in &any_of.conditions {
let WaitCondition::Channel { channel, min, max } = cond else {
continue;
};
if *min < 1 {
return Err("channel condition min must be >= 1".to_string());
}
if *max != 0 && *max < *min {
return Err("channel condition max must be 0 or >= min".to_string());
}
let queue = channels
.get(channel)
.ok_or_else(|| format!("Unknown channel `{channel}`"))?;
let already_planned = consumed_per_channel.get(channel).copied().unwrap_or(0);
let available = queue.len().saturating_sub(already_planned);
if available < *min {
continue;
}
let take_count = if *max == 0 { *min } else { available.min(*max) };
plans.push((channel.clone(), take_count));
consumed_per_channel
.entry(channel.clone())
.and_modify(|v| *v += take_count)
.or_insert(take_count);
}
if plans.is_empty() {
return Ok(None);
}
if plans.len() == 1 {
let (channel, take_count) = &plans[0];
let queue = channels
.get_mut(channel)
.ok_or_else(|| format!("Unknown channel `{channel}`"))?;
let value = pop_queue_value(queue, *take_count);
return Ok(Some(WaitEvent::Channel {
channel: channel.clone(),
value,
}));
}
let mut matched = Vec::with_capacity(plans.len());
for (channel, take_count) in plans {
let queue = channels
.get_mut(&channel)
.ok_or_else(|| format!("Unknown channel `{channel}`"))?;
let value = pop_queue_value(queue, take_count);
matched.push(json!({
"channel": channel,
"value": value,
}));
}
Ok(Some(WaitEvent::Channel {
channel: "__any_of__".to_string(),
value: Value::Array(matched),
}))
}
}
fn pop_queue_value(queue: &mut VecDeque<Value>, take_count: usize) -> Value {
if take_count == 1 {
return queue.pop_front().unwrap_or(Value::Null);
}
let mut values = Vec::with_capacity(take_count);
for _ in 0..take_count {
if let Some(v) = queue.pop_front() {
values.push(v);
}
}
Value::Array(values)
}
#[derive(Debug, Deserialize)]
@@ -2,10 +2,12 @@ from .state import (
AdvancedStateGraph,
AnyOfCondition,
ChannelCondition,
ConditionResult,
CompiledGraphEngine,
Context,
GraphRunHandler,
TimerCondition,
WaitForResult,
any_of,
channel_condition,
timer_condition,
@@ -19,6 +21,8 @@ __all__ = [
"ChannelCondition",
"TimerCondition",
"AnyOfCondition",
"ConditionResult",
"WaitForResult",
"channel_condition",
"timer_condition",
"any_of",
@@ -40,6 +40,18 @@ class AnyOfCondition:
conditions: tuple[WaitCondition, ...]
@dataclass(frozen=True)
class ConditionResult:
met: bool
channel_name: str | None = None
values: list[Any] | None = None
@dataclass(frozen=True)
class WaitForResult:
conditions: list[ConditionResult]
WaitCondition = ChannelCondition | TimerCondition
_EXECUTOR_LOCK = threading.Lock()
@@ -183,7 +195,7 @@ class Context:
def __init__(self, run: _GraphEngineRun) -> None:
self._run = run
async def wait_for(self, target: WaitCondition | AnyOfCondition) -> Any:
async def wait_for(self, target: WaitCondition | AnyOfCondition) -> WaitForResult:
resumed = self._run._consume_resume_event(target)
if resumed is not None:
return resumed
@@ -287,25 +299,31 @@ class _GraphEngineRun:
def publish_nowait(self, channel: str, value: Any) -> None:
self._publish_sync(channel, value)
async def wait_for(self, target: WaitCondition | AnyOfCondition) -> Any:
async def wait_for(self, target: WaitCondition | AnyOfCondition) -> WaitForResult:
if isinstance(target, ChannelCondition):
value = await self._wait_for_channel_values(
target.channel, min=target.min, max=target.max
)
return {
"condition": "channel",
"channel": target.channel,
"value": value,
}
return WaitForResult(
conditions=[
ConditionResult(
met=True,
channel_name=target.channel,
values=_normalize_channel_values(value),
)
]
)
if isinstance(target, TimerCondition):
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
await loop.run_in_executor(
_advanced_graph_executor(),
self._rust_engine.wait_timer,
target.seconds,
)
return WaitForResult(conditions=[ConditionResult(met=True)])
if isinstance(target, AnyOfCondition):
return await self._wait_for_any_of(target)
raw_event = await self._wait_for_any_of(target)
return _wait_for_result_from_any_of_event(target, raw_event)
raise ValueError(f"Unsupported wait condition type: {type(target)!r}")
async def _wait_for_channel_values(
@@ -327,7 +345,7 @@ class _GraphEngineRun:
)
return event["value"]
async def _wait_for_any_of(self, condition: AnyOfCondition) -> Any:
async def _wait_for_any_of(self, condition: AnyOfCondition) -> dict[str, Any]:
if not condition.conditions:
raise ValueError("any_of() requires at least one condition")
payload = {
@@ -390,12 +408,14 @@ class _GraphEngineRun:
def _set_resume_event(self, event: dict[str, Any] | None) -> None:
self._local.resume_event = event
def _consume_resume_event(self, target: WaitCondition | AnyOfCondition) -> Any | None:
def _consume_resume_event(
self, target: WaitCondition | AnyOfCondition
) -> WaitForResult | None:
event = cast(dict[str, Any] | None, getattr(self._local, "resume_event", None))
if event is None:
return None
self._local.resume_event = None
return event
return _wait_for_result_from_resume_event(target, event)
def _run_awaitable_in_worker(self, awaitable: Coroutine[Any, Any, Any]) -> Any:
# Create and close a dedicated loop per execution to avoid
@@ -490,6 +510,82 @@ def any_of(*conditions: WaitCondition) -> AnyOfCondition:
return AnyOfCondition(conditions=tuple(conditions))
def _normalize_channel_values(value: Any) -> list[Any]:
if isinstance(value, list):
return value
return [value]
def _wait_for_result_from_resume_event(
target: WaitCondition | AnyOfCondition, event: dict[str, Any]
) -> WaitForResult:
if isinstance(target, ChannelCondition):
return WaitForResult(
conditions=[
ConditionResult(
met=True,
channel_name=target.channel,
values=_normalize_channel_values(event.get("value")),
)
]
)
if isinstance(target, TimerCondition):
return WaitForResult(conditions=[ConditionResult(met=True)])
return _wait_for_result_from_any_of_event(target, event)
def _wait_for_result_from_any_of_event(
target: AnyOfCondition, event: dict[str, Any]
) -> WaitForResult:
results = [ConditionResult(met=False) for _ in target.conditions]
condition = event.get("condition")
if condition == "timer":
for idx, cond in enumerate(target.conditions):
if isinstance(cond, TimerCondition):
results[idx] = ConditionResult(met=True)
break
return WaitForResult(conditions=results)
if condition != "channel":
return WaitForResult(conditions=results)
channel = cast(str | None, event.get("channel"))
value = event.get("value")
if channel == "__any_of__" and isinstance(value, list):
matched = list(value)
cursor = 0
for idx, cond in enumerate(target.conditions):
if not isinstance(cond, ChannelCondition):
continue
if cursor >= len(matched):
continue
item = matched[cursor]
if (
isinstance(item, dict)
and item.get("channel") == cond.channel
and "value" in item
):
results[idx] = ConditionResult(
met=True,
channel_name=cond.channel,
values=_normalize_channel_values(item.get("value")),
)
cursor += 1
return WaitForResult(conditions=results)
for idx, cond in enumerate(target.conditions):
if isinstance(cond, ChannelCondition) and cond.channel == channel:
results[idx] = ConditionResult(
met=True,
channel_name=cond.channel,
values=_normalize_channel_values(value),
)
break
return WaitForResult(conditions=results)
def _condition_to_rust(condition: WaitCondition) -> dict[str, Any]:
if isinstance(condition, ChannelCondition):
return {
@@ -1,7 +1,12 @@
import pytest
from typing_extensions import TypedDict
from saf_python_sdk.advanced_graph import AdvancedStateGraph, Context, channel_condition
from saf_python_sdk.advanced_graph import (
AdvancedStateGraph,
Context,
any_of,
channel_condition,
)
from saf_python_sdk.types import Command, Send
pytestmark = pytest.mark.anyio
@@ -77,8 +82,8 @@ async def test_channel_wait_respects_max_m() -> None:
return Command(update=state, goto=Send("wait_node", None))
async def wait_node(ctx: Context, _input: None, state: PrimitiveState) -> dict[str, object]:
event = await ctx.wait_for(channel_condition("events", min=2, max=4))
values = event["value"]
result = await ctx.wait_for(channel_condition("events", min=2, max=4))
values = result.conditions[0].values or []
assert isinstance(values, list)
return {"counter": len(values), "logs": values, "done": "ok"}
@@ -90,3 +95,54 @@ async def test_channel_wait_respects_max_m() -> None:
assert result["logs"] == ["a", "b", "c"]
assert result["done"] == "ok"
async def test_any_of_consumes_all_ready_channels() -> None:
graph = AdvancedStateGraph(PrimitiveState)
graph.add_async_channel("alpha", str)
graph.add_async_channel("beta", str)
async def start_node(ctx: Context, state: PrimitiveState) -> Command:
ctx.publish_to_channel("alpha", "a1")
ctx.publish_to_channel("beta", "b1")
return Command(update=state, goto=Send("wait_node", None))
async def wait_node(ctx: Context, _input: None, state: PrimitiveState) -> Command:
first = await ctx.wait_for(
any_of(channel_condition("alpha"), channel_condition("beta"))
)
assert len(first.conditions) == 2
assert first.conditions[0].met is True
assert first.conditions[0].channel_name == "alpha"
assert first.conditions[0].values == ["a1"]
assert first.conditions[1].met is True
assert first.conditions[1].channel_name == "beta"
assert first.conditions[1].values == ["b1"]
ctx.publish_to_channel("beta", "b2")
return Command(
update={"counter": 1, "logs": ["matched=2"], "done": None},
goto=Send("verify_node", None),
)
async def verify_node(
ctx: Context, _input: None, state: PrimitiveState
) -> dict[str, object]:
second = await ctx.wait_for(channel_condition("beta"))
values = second.conditions[0].values or []
return {
"counter": 2,
"logs": [*state["logs"], f"beta={values[0]}"],
"done": "ok",
}
graph.add_entry_node(start_node)
graph.add_node(wait_node)
graph.add_finish_node(verify_node)
result = await graph.compile().ainvoke({"counter": 0, "logs": [], "done": None})
assert result["counter"] == 2
assert result["logs"] == [
"matched=2",
"beta=b2",
]
assert result["done"] == "ok"
@@ -92,7 +92,7 @@ def build_main_agent(planner: MockLLM, sub_agent: Any) -> Any:
async def wait_node(ctx: Context, state: MainAgentState) -> Command:
# Lightweight interrupt: only this node blocks for the next relevant signal.
event = await ctx.wait_for(
result = await ctx.wait_for(
any_of(
channel_condition("tool_completion_channel"),
channel_condition("subagent_completion_channel"),
@@ -100,21 +100,33 @@ def build_main_agent(planner: MockLLM, sub_agent: Any) -> Any:
timer_condition(seconds=1),
)
)
if event["condition"] == "channel":
channel = event["channel"]
payload = event["value"]
if channel == "tool_completion_channel":
state["output"].append(f"tool: {payload}")
elif channel == "subagent_completion_channel":
state["output"].append(f"sub_agent: {payload}")
elif channel == "user_input_channel":
state["output"].append(f"user_input: {payload}")
had_channel_update = False
for item in result.conditions:
if not item.met:
continue
if item.channel_name == "tool_completion_channel":
payloads = item.values or []
for payload in payloads:
state["output"].append(f"tool: {payload}")
had_channel_update = True
elif item.channel_name == "subagent_completion_channel":
payloads = item.values or []
for payload in payloads:
state["output"].append(f"sub_agent: {payload}")
had_channel_update = True
elif item.channel_name == "user_input_channel":
payloads = item.values or []
for payload in payloads:
state["output"].append(f"user_input: {payload}")
had_channel_update = True
if had_channel_update:
# State changed -> ask planner what to do next.
return Command(update=state, goto=Send("llm_node", None))
else:
state["output"].append("timer: no updates yet")
# No meaningful state change -> keep waiting without calling planner.
return Command(update=state, goto=Send("wait_node", None))
state["output"].append("timer: no updates yet")
# No meaningful state change -> keep waiting without calling planner.
return Command(update=state, goto=Send("wait_node", None))
async def tool_node(ctx: Context, tool_input: str) -> None:
await asyncio.sleep(0.1)