mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-12 04:37:51 +02:00
allof
This commit is contained in:
@@ -105,13 +105,16 @@ type Context struct {
|
||||
resumeEvent *WaitEvent
|
||||
}
|
||||
|
||||
func (c *Context) WaitFor(cond AnyOfCondition) (WaitForResult, error) {
|
||||
func (c *Context) WaitFor(target WaitTarget) (WaitForResult, error) {
|
||||
if target == nil {
|
||||
return WaitForResult{}, fmt.Errorf("wait target cannot be nil")
|
||||
}
|
||||
if c.resumeEvent != nil {
|
||||
event := *c.resumeEvent
|
||||
c.resumeEvent = nil
|
||||
return waitForResultFromRaw(cond, event), nil
|
||||
return waitForResultFromRaw(target, event), nil
|
||||
}
|
||||
return WaitForResult{}, ErrWaitRequested{Condition: cond}
|
||||
return WaitForResult{}, ErrWaitRequested{Target: target}
|
||||
}
|
||||
|
||||
func (c *Context) PublishToChannel(channel string, value any) error {
|
||||
@@ -403,16 +406,26 @@ func unwrapResumeInput(input any) (any, *WaitEvent) {
|
||||
return rawArg, &event
|
||||
}
|
||||
|
||||
func waitForResultFromRaw(cond AnyOfCondition, event WaitEvent) WaitForResult {
|
||||
func waitForResultFromRaw(target WaitTarget, event WaitEvent) WaitForResult {
|
||||
conditions := target.waitConditions()
|
||||
result := WaitForResult{
|
||||
Conditions: make([]ConditionResult, len(cond.Conditions)),
|
||||
Conditions: make([]ConditionResult, len(conditions)),
|
||||
}
|
||||
if target.waitKind() == "all_of" {
|
||||
for i, cond := range conditions {
|
||||
if isTimerCondition(cond) {
|
||||
result.Conditions[i] = ConditionResult{Met: true}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if event.Condition == "timer" {
|
||||
for i, raw := range cond.Conditions {
|
||||
if kind, _ := raw["kind"].(string); kind == "timer" {
|
||||
for i, cond := range conditions {
|
||||
if isTimerCondition(cond) {
|
||||
result.Conditions[i] = ConditionResult{Met: true}
|
||||
break
|
||||
if target.waitKind() == "any_of" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
@@ -422,19 +435,18 @@ func waitForResultFromRaw(cond AnyOfCondition, event WaitEvent) WaitForResult {
|
||||
return result
|
||||
}
|
||||
|
||||
if event.Channel == "__any_of__" {
|
||||
if event.Channel == "__any_of__" || event.Channel == "__all_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) {
|
||||
for i, cond := range conditions {
|
||||
channelName, ok := channelNameOfCondition(cond)
|
||||
if !ok || cursor >= len(matched) {
|
||||
continue
|
||||
}
|
||||
channelName, _ := raw["channel"].(string)
|
||||
if channelName == matched[cursor].Channel {
|
||||
result.Conditions[i] = ConditionResult{
|
||||
Met: true,
|
||||
@@ -449,12 +461,11 @@ func waitForResultFromRaw(cond AnyOfCondition, event WaitEvent) WaitForResult {
|
||||
|
||||
var value any
|
||||
_ = json.Unmarshal(event.Value, &value)
|
||||
for i, raw := range cond.Conditions {
|
||||
kind, _ := raw["kind"].(string)
|
||||
if kind != "channel" {
|
||||
for i, cond := range conditions {
|
||||
channelName, ok := channelNameOfCondition(cond)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
channelName, _ := raw["channel"].(string)
|
||||
if channelName == event.Channel {
|
||||
result.Conditions[i] = ConditionResult{
|
||||
Met: true,
|
||||
@@ -467,6 +478,29 @@ func waitForResultFromRaw(cond AnyOfCondition, event WaitEvent) WaitForResult {
|
||||
return result
|
||||
}
|
||||
|
||||
func isTimerCondition(cond WaitCondition) bool {
|
||||
switch cond.(type) {
|
||||
case TimerCondition, *TimerCondition:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func channelNameOfCondition(cond WaitCondition) (string, bool) {
|
||||
switch c := cond.(type) {
|
||||
case ChannelCondition:
|
||||
return c.Channel, true
|
||||
case *ChannelCondition:
|
||||
if c == nil {
|
||||
return "", false
|
||||
}
|
||||
return c.Channel, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func toValues(value any) []any {
|
||||
if value == nil {
|
||||
return []any{}
|
||||
|
||||
@@ -80,7 +80,7 @@ func goNodeCallback(userData C.ulong, node *C.char, argJSON *C.char, stateJSON *
|
||||
cmd, err := ctx.exec(nodeName, nodeInput, state)
|
||||
if err != nil {
|
||||
if waitReq, ok := AsErrWaitRequested(err); ok {
|
||||
return cCallbackEnvelopeSuspend(waitReq.Condition)
|
||||
return cCallbackEnvelopeSuspend(waitReq.Target)
|
||||
}
|
||||
return cCallbackEnvelopeError(err.Error())
|
||||
}
|
||||
@@ -234,6 +234,35 @@ func (e *RustEngine) WaitAnyOf(cond AnyOfCondition) (WaitEvent, error) {
|
||||
return event, nil
|
||||
}
|
||||
|
||||
func (e *RustEngine) WaitAllOf(cond AllOfCondition) (WaitEvent, error) {
|
||||
payload, err := json.Marshal(cond)
|
||||
if err != nil {
|
||||
return WaitEvent{}, fmt.Errorf("marshal all_of: %w", err)
|
||||
}
|
||||
cpayload := C.CString(string(payload))
|
||||
defer C.free(unsafe.Pointer(cpayload))
|
||||
resp := C.rc_wait_all_of_json(e.ptr, cpayload)
|
||||
defer C.rc_string_free(resp)
|
||||
|
||||
raw := C.GoString(resp)
|
||||
var status struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error"`
|
||||
Event json.RawMessage `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &status); err != nil {
|
||||
return WaitEvent{}, fmt.Errorf("decode rust wait response: %w", err)
|
||||
}
|
||||
if !status.OK {
|
||||
return WaitEvent{}, fmt.Errorf("rust wait failed: %s", status.Error)
|
||||
}
|
||||
var event WaitEvent
|
||||
if err := json.Unmarshal(status.Event, &event); err != nil {
|
||||
return WaitEvent{}, fmt.Errorf("decode wait event: %w", err)
|
||||
}
|
||||
return event, nil
|
||||
}
|
||||
|
||||
func (e *RustEngine) RunGraph(
|
||||
entryPoint string,
|
||||
finishPoint string,
|
||||
@@ -309,13 +338,29 @@ func cCallbackEnvelopeError(message string) *C.char {
|
||||
return C.CString(string(raw))
|
||||
}
|
||||
|
||||
func cCallbackEnvelopeSuspend(cond AnyOfCondition) *C.char {
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"ok": true,
|
||||
"suspend": map[string]any{
|
||||
func cCallbackEnvelopeSuspend(target WaitTarget) *C.char {
|
||||
if target == nil {
|
||||
return cCallbackEnvelopeError("wait requested with nil target")
|
||||
}
|
||||
kind := target.waitKind()
|
||||
if kind != "any_of" && kind != "all_of" {
|
||||
return cCallbackEnvelopeError(fmt.Sprintf("unsupported wait target kind `%s`", kind))
|
||||
}
|
||||
var payload map[string]any
|
||||
if kind == "any_of" {
|
||||
payload = map[string]any{
|
||||
"kind": "any_of",
|
||||
"any_of": cond,
|
||||
},
|
||||
"any_of": AnyOfCondition{Conditions: target.waitConditions()},
|
||||
}
|
||||
} else {
|
||||
payload = map[string]any{
|
||||
"kind": "all_of",
|
||||
"all_of": AllOfCondition{Conditions: target.waitConditions()},
|
||||
}
|
||||
}
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"ok": true,
|
||||
"suspend": payload,
|
||||
})
|
||||
return C.CString(string(raw))
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ package advancedgraph
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
type WaitCondition interface {
|
||||
toAny() map[string]any
|
||||
json.Marshaler
|
||||
}
|
||||
|
||||
type ChannelCondition struct {
|
||||
@@ -15,40 +17,128 @@ type ChannelCondition struct {
|
||||
Max int
|
||||
}
|
||||
|
||||
func (c ChannelCondition) toAny() map[string]any {
|
||||
type channelConditionJSON struct {
|
||||
Kind string `json:"kind"`
|
||||
Channel string `json:"channel"`
|
||||
Min int `json:"min"`
|
||||
Max int `json:"max"`
|
||||
}
|
||||
|
||||
func (c ChannelCondition) MarshalJSON() ([]byte, error) {
|
||||
min := c.Min
|
||||
if min <= 0 {
|
||||
min = 1
|
||||
}
|
||||
return map[string]any{
|
||||
"kind": "channel",
|
||||
"channel": c.Channel,
|
||||
"min": min,
|
||||
"max": c.Max,
|
||||
}
|
||||
return json.Marshal(channelConditionJSON{
|
||||
Kind: "channel",
|
||||
Channel: c.Channel,
|
||||
Min: min,
|
||||
Max: c.Max,
|
||||
})
|
||||
}
|
||||
|
||||
type TimerCondition struct {
|
||||
Seconds float64
|
||||
}
|
||||
|
||||
func (t TimerCondition) toAny() map[string]any {
|
||||
return map[string]any{
|
||||
"kind": "timer",
|
||||
"seconds": t.Seconds,
|
||||
}
|
||||
type timerConditionJSON struct {
|
||||
Kind string `json:"kind"`
|
||||
Seconds float64 `json:"seconds"`
|
||||
}
|
||||
|
||||
func (t TimerCondition) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(timerConditionJSON{
|
||||
Kind: "timer",
|
||||
Seconds: t.Seconds,
|
||||
})
|
||||
}
|
||||
|
||||
type AnyOfCondition struct {
|
||||
Conditions []map[string]any `json:"conditions"`
|
||||
Conditions []WaitCondition
|
||||
}
|
||||
|
||||
type AllOfCondition struct {
|
||||
Conditions []WaitCondition
|
||||
}
|
||||
|
||||
type WaitTarget interface {
|
||||
waitKind() string
|
||||
waitConditions() []WaitCondition
|
||||
}
|
||||
|
||||
func (a AnyOfCondition) waitKind() string {
|
||||
return "any_of"
|
||||
}
|
||||
|
||||
func (a AnyOfCondition) waitConditions() []WaitCondition {
|
||||
return a.Conditions
|
||||
}
|
||||
|
||||
func (a AllOfCondition) waitKind() string {
|
||||
return "all_of"
|
||||
}
|
||||
|
||||
func (a AllOfCondition) waitConditions() []WaitCondition {
|
||||
return a.Conditions
|
||||
}
|
||||
|
||||
func AnyOf(conditions ...WaitCondition) AnyOfCondition {
|
||||
result := AnyOfCondition{Conditions: make([]map[string]any, 0, len(conditions))}
|
||||
for _, cond := range conditions {
|
||||
result.Conditions = append(result.Conditions, cond.toAny())
|
||||
return AnyOfCondition{Conditions: append([]WaitCondition{}, conditions...)}
|
||||
}
|
||||
|
||||
func AllOf(conditions ...WaitCondition) AllOfCondition {
|
||||
return AllOfCondition{Conditions: append([]WaitCondition{}, conditions...)}
|
||||
}
|
||||
|
||||
func (a AnyOfCondition) MarshalJSON() ([]byte, error) {
|
||||
result := struct {
|
||||
Conditions []json.RawMessage `json:"conditions"`
|
||||
}{
|
||||
Conditions: make([]json.RawMessage, 0, len(a.Conditions)),
|
||||
}
|
||||
for i, cond := range a.Conditions {
|
||||
if isNilWaitCondition(cond) {
|
||||
return nil, fmt.Errorf("any_of condition[%d] is nil", i)
|
||||
}
|
||||
raw, err := cond.MarshalJSON()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode any_of condition[%d]: %w", i, err)
|
||||
}
|
||||
result.Conditions = append(result.Conditions, json.RawMessage(raw))
|
||||
}
|
||||
return json.Marshal(result)
|
||||
}
|
||||
|
||||
func (a AllOfCondition) MarshalJSON() ([]byte, error) {
|
||||
result := struct {
|
||||
Conditions []json.RawMessage `json:"conditions"`
|
||||
}{
|
||||
Conditions: make([]json.RawMessage, 0, len(a.Conditions)),
|
||||
}
|
||||
for i, cond := range a.Conditions {
|
||||
if isNilWaitCondition(cond) {
|
||||
return nil, fmt.Errorf("all_of condition[%d] is nil", i)
|
||||
}
|
||||
raw, err := cond.MarshalJSON()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode all_of condition[%d]: %w", i, err)
|
||||
}
|
||||
result.Conditions = append(result.Conditions, json.RawMessage(raw))
|
||||
}
|
||||
return json.Marshal(result)
|
||||
}
|
||||
|
||||
func isNilWaitCondition(cond WaitCondition) bool {
|
||||
if cond == nil {
|
||||
return true
|
||||
}
|
||||
v := reflect.ValueOf(cond)
|
||||
switch v.Kind() {
|
||||
case reflect.Ptr, reflect.Interface, reflect.Slice, reflect.Map, reflect.Func:
|
||||
return v.IsNil()
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
type WaitEvent struct {
|
||||
@@ -79,7 +169,7 @@ type Command struct {
|
||||
}
|
||||
|
||||
type ErrWaitRequested struct {
|
||||
Condition AnyOfCondition
|
||||
Target WaitTarget
|
||||
}
|
||||
|
||||
func (e ErrWaitRequested) Error() string {
|
||||
|
||||
@@ -32,7 +32,7 @@ func (c *Context) Interrupt(name string) (any, error) {
|
||||
if waitReq, ok := ag.AsErrWaitRequested(err); ok {
|
||||
return nil, errInterruptRequested{
|
||||
Name: name,
|
||||
Condition: waitReq.Condition,
|
||||
Condition: waitReq.Target,
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
@@ -73,7 +73,7 @@ func (c *Context) Interrupt(name string) (any, error) {
|
||||
|
||||
type errInterruptRequested struct {
|
||||
Name string
|
||||
Condition ag.AnyOfCondition
|
||||
Condition ag.WaitTarget
|
||||
}
|
||||
|
||||
func (e errInterruptRequested) Error() string {
|
||||
@@ -178,13 +178,13 @@ func (g *BasicStateGraph[StateT]) Compile() *CompiledBasicStateGraph[StateT] {
|
||||
if err != nil {
|
||||
if interruptReq, ok := asErrInterruptRequested(err); ok {
|
||||
cond := interruptReq.Condition
|
||||
if len(cond.Conditions) == 0 {
|
||||
if cond == nil {
|
||||
cond = ag.AnyOf(ag.ChannelCondition{
|
||||
Channel: internalInterruptChannel,
|
||||
Min: 1,
|
||||
})
|
||||
}
|
||||
return ag.Command{}, ag.ErrWaitRequested{Condition: cond}
|
||||
return ag.Command{}, ag.ErrWaitRequested{Target: cond}
|
||||
}
|
||||
return ag.Command{}, err
|
||||
}
|
||||
|
||||
@@ -291,3 +291,139 @@ func TestAnyOfConsumesAllReadyChannels(t *testing.T) {
|
||||
t.Fatalf("unexpected done: %v", result.Done)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *primitiveWorkflow) startAllOfTwoChannelsNode(ctx *ag.Context, _ any, state primitiveState) (ag.Command, error) {
|
||||
if err := ctx.PublishToChannel("alpha", "a1"); err != nil {
|
||||
return ag.Command{}, err
|
||||
}
|
||||
return ag.Command{
|
||||
Update: state,
|
||||
Goto: []ag.Send{
|
||||
{Node: w.waitAllOfTwoChannelsNode, NodeInput: nil},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *primitiveWorkflow) waitAllOfTwoChannelsNode(ctx *ag.Context, _ any, state primitiveState) (ag.Command, error) {
|
||||
result, err := ctx.WaitFor(ag.AllOf(
|
||||
ag.ChannelCondition{Channel: "alpha"},
|
||||
ag.ChannelCondition{Channel: "beta"},
|
||||
))
|
||||
if err != nil {
|
||||
return ag.Command{}, err
|
||||
}
|
||||
if len(result.Conditions) != 2 {
|
||||
return ag.Command{}, fmt.Errorf("expected 2 condition results, got %d", len(result.Conditions))
|
||||
}
|
||||
if !result.Conditions[0].Met || result.Conditions[0].ChannelName != "alpha" || len(result.Conditions[0].Values) != 1 || result.Conditions[0].Values[0] != "a1" {
|
||||
return ag.Command{}, fmt.Errorf("unexpected alpha condition result: %#v", result.Conditions[0])
|
||||
}
|
||||
if !result.Conditions[1].Met || result.Conditions[1].ChannelName != "beta" || len(result.Conditions[1].Values) != 1 || result.Conditions[1].Values[0] != "b1" {
|
||||
return ag.Command{}, fmt.Errorf("unexpected beta condition result: %#v", result.Conditions[1])
|
||||
}
|
||||
state.Count = 2
|
||||
state.Logs = []string{"all_of_channels_ok"}
|
||||
state.Done = "ok"
|
||||
return ag.Command{Update: state}, nil
|
||||
}
|
||||
|
||||
func TestAllOfWaitsUntilAllChannelsAreReady(t *testing.T) {
|
||||
workflow := &primitiveWorkflow{}
|
||||
graph := ag.NewAdvancedStateGraph[primitiveState]()
|
||||
graph.AddAsyncChannel("alpha")
|
||||
graph.AddAsyncChannel("beta")
|
||||
graph.AddEntryNode(workflow.startAllOfTwoChannelsNode)
|
||||
graph.AddFinishNode(workflow.waitAllOfTwoChannelsNode)
|
||||
|
||||
handler, err := graph.Compile().Start(nil, primitiveState{
|
||||
Count: 0,
|
||||
Logs: []string{},
|
||||
Done: "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("start failed: %v", err)
|
||||
}
|
||||
if err := handler.PublishToChannel("beta", "b1"); err != nil {
|
||||
t.Fatalf("publish beta 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) != 1 || result.Logs[0] != "all_of_channels_ok" {
|
||||
t.Fatalf("unexpected logs: %#v", result.Logs)
|
||||
}
|
||||
if result.Done != "ok" {
|
||||
t.Fatalf("unexpected done: %v", result.Done)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *primitiveWorkflow) startAllOfChannelTimerNode(ctx *ag.Context, _ any, state primitiveState) (ag.Command, error) {
|
||||
if err := ctx.PublishToChannel("alpha", "a1"); err != nil {
|
||||
return ag.Command{}, err
|
||||
}
|
||||
return ag.Command{
|
||||
Update: state,
|
||||
Goto: []ag.Send{
|
||||
{Node: w.waitAllOfChannelTimerNode, NodeInput: nil},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *primitiveWorkflow) waitAllOfChannelTimerNode(ctx *ag.Context, _ any, state primitiveState) (ag.Command, error) {
|
||||
result, err := ctx.WaitFor(ag.AllOf(
|
||||
ag.ChannelCondition{Channel: "alpha"},
|
||||
ag.TimerCondition{Seconds: 0.05},
|
||||
))
|
||||
if err != nil {
|
||||
return ag.Command{}, err
|
||||
}
|
||||
if len(result.Conditions) != 2 {
|
||||
return ag.Command{}, fmt.Errorf("expected 2 condition results, got %d", len(result.Conditions))
|
||||
}
|
||||
if !result.Conditions[0].Met || result.Conditions[0].ChannelName != "alpha" || len(result.Conditions[0].Values) != 1 || result.Conditions[0].Values[0] != "a1" {
|
||||
return ag.Command{}, fmt.Errorf("unexpected channel condition result: %#v", result.Conditions[0])
|
||||
}
|
||||
if !result.Conditions[1].Met {
|
||||
return ag.Command{}, fmt.Errorf("timer condition should be met: %#v", result.Conditions[1])
|
||||
}
|
||||
state.Count = 1
|
||||
state.Logs = []string{"all_of_channel_timer_ok"}
|
||||
state.Done = "ok"
|
||||
return ag.Command{Update: state}, nil
|
||||
}
|
||||
|
||||
func TestAllOfChannelAndTimerMarksBothConditions(t *testing.T) {
|
||||
workflow := &primitiveWorkflow{}
|
||||
graph := ag.NewAdvancedStateGraph[primitiveState]()
|
||||
graph.AddAsyncChannel("alpha")
|
||||
graph.AddEntryNode(workflow.startAllOfChannelTimerNode)
|
||||
graph.AddFinishNode(workflow.waitAllOfChannelTimerNode)
|
||||
|
||||
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 != 1 {
|
||||
t.Fatalf("unexpected count: %v", result.Count)
|
||||
}
|
||||
if len(result.Logs) != 1 || result.Logs[0] != "all_of_channel_timer_ok" {
|
||||
t.Fatalf("unexpected logs: %#v", result.Logs)
|
||||
}
|
||||
if result.Done != "ok" {
|
||||
t.Fatalf("unexpected done: %v", result.Done)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ char* rc_add_async_channel(Engine* ptr, const char* channel);
|
||||
char* rc_add_custom_output_stream(Engine* ptr, const char* stream_name);
|
||||
char* rc_publish_json(Engine* ptr, const char* channel, const char* value_json);
|
||||
char* rc_wait_any_of_json(Engine* ptr, const char* any_of_json);
|
||||
char* rc_wait_all_of_json(Engine* ptr, const char* all_of_json);
|
||||
char* rc_start_stream(Engine* ptr, const char* stream_mode);
|
||||
char* rc_receive_stream_json(Engine* ptr, const char* stream_name);
|
||||
char* rc_send_custom_stream_event(Engine* ptr, const char* stream_name, const char* value_json);
|
||||
|
||||
+199
-68
@@ -32,6 +32,11 @@ pub struct AnyOfCondition {
|
||||
pub conditions: Vec<WaitCondition>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct AllOfCondition {
|
||||
pub conditions: Vec<WaitCondition>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "condition")]
|
||||
pub enum WaitEvent {
|
||||
@@ -51,6 +56,8 @@ pub enum WaitRequest {
|
||||
Condition { condition: WaitCondition },
|
||||
#[serde(rename = "any_of")]
|
||||
AnyOf { any_of: AnyOfCondition },
|
||||
#[serde(rename = "all_of")]
|
||||
AllOf { all_of: AllOfCondition },
|
||||
}
|
||||
|
||||
pub struct SendPayload<A> {
|
||||
@@ -422,6 +429,7 @@ impl Engine {
|
||||
self.wait_for_any_of_async(&any_of).await
|
||||
}
|
||||
WaitRequest::AnyOf { any_of } => self.wait_for_any_of_async(any_of).await,
|
||||
WaitRequest::AllOf { all_of } => self.wait_for_all_of_async(all_of).await,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,15 +446,8 @@ impl Engine {
|
||||
}
|
||||
|
||||
let started = Instant::now();
|
||||
let mut min_timer: Option<f64> = None;
|
||||
for cond in &any_of.conditions {
|
||||
if let WaitCondition::Timer { seconds } = cond {
|
||||
if *seconds <= 0.0 {
|
||||
return Err("timer condition must be > 0".to_string());
|
||||
}
|
||||
min_timer = Some(min_timer.map_or(*seconds, |x| x.min(*seconds)));
|
||||
}
|
||||
}
|
||||
let timer_bounds = validate_wait_conditions(&any_of.conditions)?;
|
||||
let min_timer = timer_bounds.min_seconds;
|
||||
|
||||
loop {
|
||||
if let Some(event) = self.try_take_any_of_channel_events(any_of)? {
|
||||
@@ -472,75 +473,205 @@ impl Engine {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn wait_for_all_of_async(
|
||||
&self,
|
||||
all_of: &AllOfCondition,
|
||||
) -> Result<WaitEvent, String> {
|
||||
debug_log(&format!(
|
||||
"Engine::wait_for_all_of_async(conditions={})",
|
||||
all_of.conditions.len()
|
||||
));
|
||||
if all_of.conditions.is_empty() {
|
||||
return Err("all_of requires at least one condition".to_string());
|
||||
}
|
||||
|
||||
let started = Instant::now();
|
||||
let timer_bounds = validate_wait_conditions(&all_of.conditions)?;
|
||||
let max_timer = timer_bounds.max_seconds;
|
||||
let has_channel_condition = all_of
|
||||
.conditions
|
||||
.iter()
|
||||
.any(|c| matches!(c, WaitCondition::Channel { .. }));
|
||||
|
||||
let mut consumed_channel_event: Option<WaitEvent> = None;
|
||||
let mut channels_met = !has_channel_condition;
|
||||
|
||||
loop {
|
||||
if !channels_met {
|
||||
if let Some(event) = self.try_take_all_of_channel_events(all_of)? {
|
||||
consumed_channel_event = Some(event);
|
||||
channels_met = true;
|
||||
}
|
||||
}
|
||||
|
||||
let timers_met = if let Some(seconds) = max_timer {
|
||||
started.elapsed() >= Duration::from_secs_f64(seconds)
|
||||
} else {
|
||||
true
|
||||
};
|
||||
|
||||
if channels_met && timers_met {
|
||||
if let Some(event) = consumed_channel_event {
|
||||
return Ok(event);
|
||||
}
|
||||
return Ok(WaitEvent::Timer {
|
||||
seconds: max_timer.unwrap_or(0.0),
|
||||
});
|
||||
}
|
||||
|
||||
if !channels_met && !timers_met {
|
||||
let timeout = Duration::from_secs_f64(max_timer.unwrap_or(0.0));
|
||||
let elapsed = started.elapsed();
|
||||
let remaining = timeout.saturating_sub(elapsed);
|
||||
tokio::select! {
|
||||
_ = self.channel_notify.notified() => {}
|
||||
_ = tokio::time::sleep(remaining) => {}
|
||||
}
|
||||
} else if !channels_met {
|
||||
self.channel_notify.notified().await;
|
||||
} else {
|
||||
let timeout = Duration::from_secs_f64(max_timer.unwrap_or(0.0));
|
||||
let elapsed = started.elapsed();
|
||||
let remaining = timeout.saturating_sub(elapsed);
|
||||
tokio::time::sleep(remaining).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
let plans = collect_channel_plans(&channels, &any_of.conditions, false)?;
|
||||
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),
|
||||
}))
|
||||
build_channel_wait_event(&mut channels, plans, "__any_of__").map(Some)
|
||||
}
|
||||
|
||||
fn try_take_all_of_channel_events(
|
||||
&self,
|
||||
all_of: &AllOfCondition,
|
||||
) -> Result<Option<WaitEvent>, String> {
|
||||
let mut channels = self.channels.lock().expect("channels mutex poisoned");
|
||||
let plans = collect_channel_plans(&channels, &all_of.conditions, true)?;
|
||||
if plans.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
build_channel_wait_event(&mut channels, plans, "__all_of__").map(Some)
|
||||
}
|
||||
}
|
||||
|
||||
struct TimerBounds {
|
||||
min_seconds: Option<f64>,
|
||||
max_seconds: Option<f64>,
|
||||
}
|
||||
|
||||
fn validate_wait_conditions(conditions: &[WaitCondition]) -> Result<TimerBounds, String> {
|
||||
let mut min_timer: Option<f64> = None;
|
||||
let mut max_timer: Option<f64> = None;
|
||||
for cond in conditions {
|
||||
match cond {
|
||||
WaitCondition::Timer { seconds } => {
|
||||
if *seconds <= 0.0 {
|
||||
return Err("timer condition must be > 0".to_string());
|
||||
}
|
||||
min_timer = Some(min_timer.map_or(*seconds, |x| x.min(*seconds)));
|
||||
max_timer = Some(max_timer.map_or(*seconds, |x| x.max(*seconds)));
|
||||
}
|
||||
WaitCondition::Channel { min, max, .. } => {
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(TimerBounds {
|
||||
min_seconds: min_timer,
|
||||
max_seconds: max_timer,
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_channel_plans(
|
||||
channels: &HashMap<String, VecDeque<Value>>,
|
||||
conditions: &[WaitCondition],
|
||||
require_all_channels: bool,
|
||||
) -> Result<Vec<(String, usize)>, String> {
|
||||
let mut consumed_per_channel: HashMap<String, usize> = HashMap::new();
|
||||
let mut plans: Vec<(String, usize)> = Vec::new();
|
||||
let mut channel_condition_count = 0usize;
|
||||
|
||||
for cond in conditions {
|
||||
let WaitCondition::Channel { channel, min, max } = cond else {
|
||||
continue;
|
||||
};
|
||||
channel_condition_count += 1;
|
||||
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 {
|
||||
if require_all_channels {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
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 require_all_channels && channel_condition_count > 0 && plans.len() != channel_condition_count
|
||||
{
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
Ok(plans)
|
||||
}
|
||||
|
||||
fn build_channel_wait_event(
|
||||
channels: &mut HashMap<String, VecDeque<Value>>,
|
||||
plans: Vec<(String, usize)>,
|
||||
aggregate_channel_name: &str,
|
||||
) -> Result<WaitEvent, String> {
|
||||
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(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(WaitEvent::Channel {
|
||||
channel: aggregate_channel_name.to_string(),
|
||||
value: Value::Array(matched),
|
||||
})
|
||||
}
|
||||
|
||||
fn pop_queue_value(queue: &mut VecDeque<Value>, take_count: usize) -> Value {
|
||||
|
||||
+34
-1
@@ -1,6 +1,6 @@
|
||||
use crate::engine::{
|
||||
parse_callback_envelope_json, run_graph_json_with_callback, run_loop_block_on, run_loop_spawn,
|
||||
AnyOfCondition, Engine, NodeOutcome,
|
||||
AllOfCondition, AnyOfCondition, Engine, NodeOutcome,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::ffi::{CStr, CString};
|
||||
@@ -144,6 +144,39 @@ pub unsafe extern "C" fn rc_wait_any_of_json(
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
/// # Safety
|
||||
/// `ptr` must be a valid engine pointer from `rc_engine_new`.
|
||||
/// `all_of_json` must be a valid null-terminated UTF-8 string pointer.
|
||||
pub unsafe extern "C" fn rc_wait_all_of_json(
|
||||
ptr: *mut Engine,
|
||||
all_of_json: *const c_char,
|
||||
) -> *mut c_char {
|
||||
if ptr.is_null() {
|
||||
return into_c_ptr("{\"ok\":false,\"error\":\"null engine pointer\"}".to_string());
|
||||
}
|
||||
let all_of_json = match cstr_to_str(all_of_json) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return into_c_ptr(format!("{{\"ok\":false,\"error\":\"{e}\"}}")),
|
||||
};
|
||||
let all_of: AllOfCondition = match serde_json::from_str(all_of_json) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
return into_c_ptr(format!(
|
||||
"{{\"ok\":false,\"error\":\"invalid all_of JSON: {e}\"}}"
|
||||
))
|
||||
}
|
||||
};
|
||||
let result = run_loop_block_on((*ptr).wait_for_all_of_async(&all_of));
|
||||
match result {
|
||||
Ok(event) => match serde_json::to_string(&event) {
|
||||
Ok(s) => into_c_ptr(format!("{{\"ok\":true,\"event\":{s}}}")),
|
||||
Err(e) => into_c_ptr(format!("{{\"ok\":false,\"error\":\"{e}\"}}")),
|
||||
},
|
||||
Err(e) => into_c_ptr(format!("{{\"ok\":false,\"error\":\"{e}\"}}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
/// # Safety
|
||||
/// `ptr` must be a valid engine pointer from `rc_engine_new`.
|
||||
|
||||
+17
-2
@@ -1,7 +1,7 @@
|
||||
#[cfg(feature = "python-bindings")]
|
||||
use crate::engine::{
|
||||
run_graph_with_callback, run_loop_block_on, AnyOfCondition, Engine, NodeExecResult,
|
||||
NodeOutcome, SendPayload, WaitCondition, WaitEvent, WaitRequest,
|
||||
run_graph_with_callback, run_loop_block_on, AllOfCondition, AnyOfCondition, Engine,
|
||||
NodeExecResult, NodeOutcome, SendPayload, WaitCondition, WaitEvent, WaitRequest,
|
||||
};
|
||||
#[cfg(feature = "python-bindings")]
|
||||
use pyo3::exceptions::PyValueError;
|
||||
@@ -92,6 +92,12 @@ impl PyRustEngine {
|
||||
json_string_to_py_obj(py, &event_json)
|
||||
}
|
||||
|
||||
fn wait_all_of_obj(&self, py: Python<'_>, all_of_payload: Py<PyAny>) -> PyResult<Py<PyAny>> {
|
||||
let payload_json = py_obj_to_json_string(py, &all_of_payload.bind(py))?;
|
||||
let event_json = self._wait_all_of_json(&payload_json)?;
|
||||
json_string_to_py_obj(py, &event_json)
|
||||
}
|
||||
|
||||
fn _wait_any_of_json(&self, any_of_json: &str) -> PyResult<String> {
|
||||
let any_of: AnyOfCondition = serde_json::from_str(any_of_json)
|
||||
.map_err(|e| PyValueError::new_err(format!("Invalid any_of JSON: {e}")))?;
|
||||
@@ -101,6 +107,15 @@ impl PyRustEngine {
|
||||
.map_err(|e| PyValueError::new_err(format!("Serialize event failed: {e}")))
|
||||
}
|
||||
|
||||
fn _wait_all_of_json(&self, all_of_json: &str) -> PyResult<String> {
|
||||
let all_of: AllOfCondition = serde_json::from_str(all_of_json)
|
||||
.map_err(|e| PyValueError::new_err(format!("Invalid all_of JSON: {e}")))?;
|
||||
let event = run_loop_block_on(self.inner.wait_for_all_of_async(&all_of))
|
||||
.map_err(PyValueError::new_err)?;
|
||||
serde_json::to_string(&event)
|
||||
.map_err(|e| PyValueError::new_err(format!("Serialize event failed: {e}")))
|
||||
}
|
||||
|
||||
fn wait_condition_json(&self, cond_json: &str) -> PyResult<String> {
|
||||
let cond: WaitCondition = serde_json::from_str(cond_json)
|
||||
.map_err(|e| PyValueError::new_err(format!("Invalid condition JSON: {e}")))?;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
PYPI_REPOSITORY ?= pypi
|
||||
PYPI_TOKEN ?=
|
||||
PYTHON_VERSION ?= 3.13
|
||||
TEST_BASE = uv run --python $(PYTHON_VERSION) --with pytest --with anyio --with typing_extensions --with pydantic --with langgraph pytest -q -s
|
||||
TEST_BASE = uv run --python $(PYTHON_VERSION) --reinstall-package saf-python-sdk --with pytest --with anyio --with typing_extensions --with pydantic --with langgraph pytest -q -s
|
||||
|
||||
.PHONY: publish-to-pypi-saf-python-sdk all-tests test_primitives test_sub_agents test_update_elision test_run_pool_size test-benchmark
|
||||
publish-to-pypi-saf-python-sdk:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from .state import (
|
||||
AdvancedStateGraph,
|
||||
AllOfCondition,
|
||||
AnyOfCondition,
|
||||
ChannelCondition,
|
||||
ConditionResult,
|
||||
@@ -8,6 +9,7 @@ from .state import (
|
||||
GraphRunHandler,
|
||||
TimerCondition,
|
||||
WaitForResult,
|
||||
all_of,
|
||||
any_of,
|
||||
channel_condition,
|
||||
timer_condition,
|
||||
@@ -21,10 +23,12 @@ __all__ = [
|
||||
"ChannelCondition",
|
||||
"TimerCondition",
|
||||
"AnyOfCondition",
|
||||
"AllOfCondition",
|
||||
"ConditionResult",
|
||||
"WaitForResult",
|
||||
"channel_condition",
|
||||
"timer_condition",
|
||||
"any_of",
|
||||
"all_of",
|
||||
]
|
||||
|
||||
|
||||
@@ -40,6 +40,11 @@ class AnyOfCondition:
|
||||
conditions: tuple[WaitCondition, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AllOfCondition:
|
||||
conditions: tuple[WaitCondition, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConditionResult:
|
||||
met: bool
|
||||
@@ -209,7 +214,9 @@ class Context:
|
||||
def __init__(self, run: _GraphEngineRun) -> None:
|
||||
self._run = run
|
||||
|
||||
async def wait_for(self, target: WaitCondition | AnyOfCondition) -> WaitForResult:
|
||||
async def wait_for(
|
||||
self, target: WaitCondition | AnyOfCondition | AllOfCondition
|
||||
) -> WaitForResult:
|
||||
resumed = self._run._consume_resume_event(target)
|
||||
if resumed is not None:
|
||||
return resumed
|
||||
@@ -316,7 +323,9 @@ class _GraphEngineRun:
|
||||
def publish_nowait(self, channel: str, value: Any) -> None:
|
||||
self._publish_sync(channel, value)
|
||||
|
||||
async def wait_for(self, target: WaitCondition | AnyOfCondition) -> WaitForResult:
|
||||
async def wait_for(
|
||||
self, target: WaitCondition | AnyOfCondition | AllOfCondition
|
||||
) -> WaitForResult:
|
||||
if isinstance(target, ChannelCondition):
|
||||
value = await self._wait_for_channel_values(
|
||||
target.channel, min=target.min, max=target.max
|
||||
@@ -341,6 +350,9 @@ class _GraphEngineRun:
|
||||
if isinstance(target, AnyOfCondition):
|
||||
raw_event = await self._wait_for_any_of(target)
|
||||
return _wait_for_result_from_any_of_event(target, raw_event)
|
||||
if isinstance(target, AllOfCondition):
|
||||
raw_event = await self._wait_for_all_of(target)
|
||||
return _wait_for_result_from_all_of_event(target, raw_event)
|
||||
raise ValueError(f"Unsupported wait condition type: {type(target)!r}")
|
||||
|
||||
async def _wait_for_channel_values(
|
||||
@@ -375,6 +387,19 @@ class _GraphEngineRun:
|
||||
payload,
|
||||
)
|
||||
|
||||
async def _wait_for_all_of(self, condition: AllOfCondition) -> dict[str, Any]:
|
||||
if not condition.conditions:
|
||||
raise ValueError("all_of() requires at least one condition")
|
||||
payload = {
|
||||
"conditions": [_condition_to_rust(cond) for cond in condition.conditions]
|
||||
}
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(
|
||||
_advanced_graph_executor(),
|
||||
self._rust_engine.wait_all_of_obj,
|
||||
payload,
|
||||
)
|
||||
|
||||
def _publish_sync(self, channel: str, value: Any) -> None:
|
||||
self._rust_engine.publish_obj(channel, value)
|
||||
|
||||
@@ -426,7 +451,7 @@ class _GraphEngineRun:
|
||||
self._local.resume_event = event
|
||||
|
||||
def _consume_resume_event(
|
||||
self, target: WaitCondition | AnyOfCondition
|
||||
self, target: WaitCondition | AnyOfCondition | AllOfCondition
|
||||
) -> WaitForResult | None:
|
||||
event = cast(dict[str, Any] | None, getattr(self._local, "resume_event", None))
|
||||
if event is None:
|
||||
@@ -527,6 +552,12 @@ def any_of(*conditions: WaitCondition) -> AnyOfCondition:
|
||||
return AnyOfCondition(conditions=tuple(conditions))
|
||||
|
||||
|
||||
def all_of(*conditions: WaitCondition) -> AllOfCondition:
|
||||
if not conditions:
|
||||
raise ValueError("all_of() requires at least one condition")
|
||||
return AllOfCondition(conditions=tuple(conditions))
|
||||
|
||||
|
||||
def _normalize_channel_values(value: Any) -> list[Any]:
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
@@ -534,7 +565,7 @@ def _normalize_channel_values(value: Any) -> list[Any]:
|
||||
|
||||
|
||||
def _wait_for_result_from_resume_event(
|
||||
target: WaitCondition | AnyOfCondition, event: dict[str, Any]
|
||||
target: WaitCondition | AnyOfCondition | AllOfCondition, event: dict[str, Any]
|
||||
) -> WaitForResult:
|
||||
if isinstance(target, ChannelCondition):
|
||||
return WaitForResult(
|
||||
@@ -548,6 +579,8 @@ def _wait_for_result_from_resume_event(
|
||||
)
|
||||
if isinstance(target, TimerCondition):
|
||||
return WaitForResult(conditions=[ConditionResult(met=True)])
|
||||
if isinstance(target, AllOfCondition):
|
||||
return _wait_for_result_from_all_of_event(target, event)
|
||||
return _wait_for_result_from_any_of_event(target, event)
|
||||
|
||||
|
||||
@@ -603,6 +636,52 @@ def _wait_for_result_from_any_of_event(
|
||||
return WaitForResult(conditions=results)
|
||||
|
||||
|
||||
def _wait_for_result_from_all_of_event(
|
||||
target: AllOfCondition, event: dict[str, Any]
|
||||
) -> WaitForResult:
|
||||
results = [ConditionResult(met=False) for _ in target.conditions]
|
||||
condition = event.get("condition")
|
||||
|
||||
# all_of completion implies all timer conditions are satisfied.
|
||||
for idx, cond in enumerate(target.conditions):
|
||||
if isinstance(cond, TimerCondition):
|
||||
results[idx] = ConditionResult(met=True)
|
||||
|
||||
if condition != "channel":
|
||||
return WaitForResult(conditions=results)
|
||||
|
||||
channel = cast(str | None, event.get("channel"))
|
||||
value = event.get("value")
|
||||
|
||||
if channel == "__all_of__" and isinstance(value, list):
|
||||
matched_by_channel: dict[str, Any] = {}
|
||||
for item in value:
|
||||
if isinstance(item, dict) and isinstance(item.get("channel"), str):
|
||||
matched_by_channel[cast(str, item["channel"])] = item.get("value")
|
||||
|
||||
for idx, cond in enumerate(target.conditions):
|
||||
if not isinstance(cond, ChannelCondition):
|
||||
continue
|
||||
if cond.channel not in matched_by_channel:
|
||||
continue
|
||||
results[idx] = ConditionResult(
|
||||
met=True,
|
||||
channel_name=cond.channel,
|
||||
values=_normalize_channel_values(matched_by_channel[cond.channel]),
|
||||
)
|
||||
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 {
|
||||
@@ -616,7 +695,9 @@ def _condition_to_rust(condition: WaitCondition) -> dict[str, Any]:
|
||||
raise TypeError(f"Unsupported condition type: {type(condition)!r}")
|
||||
|
||||
|
||||
def _target_to_suspend_payload(target: WaitCondition | AnyOfCondition) -> dict[str, Any]:
|
||||
def _target_to_suspend_payload(
|
||||
target: WaitCondition | AnyOfCondition | AllOfCondition,
|
||||
) -> dict[str, Any]:
|
||||
if isinstance(target, AnyOfCondition):
|
||||
return {
|
||||
"kind": "any_of",
|
||||
@@ -624,6 +705,13 @@ def _target_to_suspend_payload(target: WaitCondition | AnyOfCondition) -> dict[s
|
||||
"conditions": [_condition_to_rust(cond) for cond in target.conditions]
|
||||
},
|
||||
}
|
||||
if isinstance(target, AllOfCondition):
|
||||
return {
|
||||
"kind": "all_of",
|
||||
"all_of": {
|
||||
"conditions": [_condition_to_rust(cond) for cond in target.conditions]
|
||||
},
|
||||
}
|
||||
return {"kind": "condition", "condition": _condition_to_rust(target)}
|
||||
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@ from typing_extensions import TypedDict
|
||||
from saf_python_sdk.advanced_graph import (
|
||||
AdvancedStateGraph,
|
||||
Context,
|
||||
all_of,
|
||||
any_of,
|
||||
channel_condition,
|
||||
timer_condition,
|
||||
)
|
||||
from saf_python_sdk.types import Command, Send
|
||||
|
||||
@@ -146,3 +148,71 @@ async def test_any_of_consumes_all_ready_channels() -> None:
|
||||
]
|
||||
assert result["done"] == "ok"
|
||||
|
||||
|
||||
async def test_all_of_waits_until_all_channels_are_ready() -> 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")
|
||||
return Command(
|
||||
update=state,
|
||||
goto=[Send("wait_node", None), Send("publish_beta_node", None)],
|
||||
)
|
||||
|
||||
async def publish_beta_node(ctx: Context, _input: None, state: PrimitiveState) -> Command:
|
||||
await ctx.wait_for(timer_condition(seconds=0.02))
|
||||
ctx.publish_to_channel("beta", "b1")
|
||||
return Command(update=state)
|
||||
|
||||
async def wait_node(ctx: Context, _input: None, state: PrimitiveState) -> dict[str, object]:
|
||||
waited = await ctx.wait_for(
|
||||
all_of(channel_condition("alpha"), channel_condition("beta"))
|
||||
)
|
||||
assert len(waited.conditions) == 2
|
||||
assert waited.conditions[0].met is True
|
||||
assert waited.conditions[0].channel_name == "alpha"
|
||||
assert waited.conditions[0].values == ["a1"]
|
||||
assert waited.conditions[1].met is True
|
||||
assert waited.conditions[1].channel_name == "beta"
|
||||
assert waited.conditions[1].values == ["b1"]
|
||||
return {"counter": 1, "logs": ["all_of_channels"], "done": "ok"}
|
||||
|
||||
graph.add_entry_node(start_node)
|
||||
graph.add_node(publish_beta_node)
|
||||
graph.add_finish_node(wait_node)
|
||||
|
||||
result = await graph.compile().ainvoke({"counter": 0, "logs": [], "done": None})
|
||||
assert result["counter"] == 1
|
||||
assert result["logs"] == ["all_of_channels"]
|
||||
assert result["done"] == "ok"
|
||||
|
||||
|
||||
async def test_all_of_channel_and_timer_marks_both_conditions() -> None:
|
||||
graph = AdvancedStateGraph(PrimitiveState)
|
||||
graph.add_async_channel("alpha", str)
|
||||
|
||||
async def start_node(ctx: Context, state: PrimitiveState) -> Command:
|
||||
ctx.publish_to_channel("alpha", "a1")
|
||||
return Command(update=state, goto=Send("wait_node", None))
|
||||
|
||||
async def wait_node(ctx: Context, _input: None, state: PrimitiveState) -> dict[str, object]:
|
||||
waited = await ctx.wait_for(
|
||||
all_of(channel_condition("alpha"), timer_condition(seconds=0.02))
|
||||
)
|
||||
assert len(waited.conditions) == 2
|
||||
assert waited.conditions[0].met is True
|
||||
assert waited.conditions[0].channel_name == "alpha"
|
||||
assert waited.conditions[0].values == ["a1"]
|
||||
assert waited.conditions[1].met is True
|
||||
return {"counter": 1, "logs": ["all_of_channel_timer"], "done": "ok"}
|
||||
|
||||
graph.add_entry_node(start_node)
|
||||
graph.add_finish_node(wait_node)
|
||||
|
||||
result = await graph.compile().ainvoke({"counter": 0, "logs": [], "done": None})
|
||||
assert result["counter"] == 1
|
||||
assert result["logs"] == ["all_of_channel_timer"]
|
||||
assert result["done"] == "ok"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user