from stringdale import Define,V,E
from stringdale.chat import Chat
Decision Diagrams - Routing Agents
def add(a,b):
return a+b
class Pow():
def __init__(self,power):
self.power = power
def __call__(self,a):
return a**self.power
def __str__(self):
return f'Pow({self.power})'
Different types of diagrams
The type of diagrams we have seen so far are called flow diagrams. In Flow diagrams:
- There are no cycles
- All nodes run based on the output
Another Type of Diagrams are Decision Diagrams. In Decision diagrams:
- You can have cycles
- After Each Node, we need to choose the next node to run based on a condition function.
Flow diagrams are the default type but we can also define them explicitly.
with Define('hello world flow',type='flow') as D:
'square',Pow(2),inputs=['Start(a=b)'],outputs=['End(square_result=.)'])
V('cube',Pow(3),inputs=['Start(a=b)'],outputs=['End(cube_result=.)'])
V(
D.draw()
Decision diagrams can be defined in a similar manner
def is_b_even(obj):
return obj['b']%2==0
with Define('Hello World Decision',type='decision') as D:
'square',Pow(2),outputs=['End(square_result=.)'])
V('cube',Pow(3),outputs=['End(cube_result=.)'])
V(# we define conditional edges by adding a cond argument to the edge
'Start->square(a=b)',cond=is_b_even)
E(# each non-end node must have at least one edge with no condition, the default edge
'Start->cube(a=b)')
E( D.draw()
We can also inline conditional edge definition in the node definition by providing a tuple of the form (edge_string,cond)
to the inputs
or outputs
keys.
with Define('Hello World Decision',type='decision') as D:
'square',Pow(2),
V(=[('Start(a=b)',is_b_even)],
inputs=['End(square_result=.)'])
outputs
'cube',Pow(3),inputs=['Start(a=b)'],outputs=['End(cube_result=.)'])
V( D.draw()
Unlike in our flow example, here either square or cube is called, but not both.
=D()
dfor trace in d.run({'a':1,'b':2}):
=True)
trace.pprint(skip_passthrough d.output
Node square:
{'input': {'a': 2}, 'output': 4}
================================================================================
{'square_result': 4}
=D()
dfor trace in d.run({'a':1,'b':3}):
=True)
trace.pprint(skip_passthrough d.output
Node cube:
{'input': {'a': 3}, 'output': 27}
================================================================================
{'cube_result': 27}
Introduce Diagram State
Often, we would like to use state from previous nodes. In flow diagrams, we can simply connect them, but in decision diagrams, this can make the diagram not well defined.
To solve this problem, stringdale has support for diagram states.
Here is a basic example of it:
with Define('Remember Input',type='decision') as D:
'square',Pow(2),inputs=[
V('Start(a=b)',is_b_even),
(
],=['End(square_result=.)'])
outputs'cube',Pow(3),inputs=['Start(a=b)'],outputs=['End(cube_result=.)'])
V(
# We can write and read from the state using nodes of the from 'State/key'
# State nodes are special nodes that are not part of the execution, but are used to store information.
'Start->State/init_a(0=a)')
E('End',inputs=['State/init_a(init_a=.)'])
V(
D.draw()=D()
dfor trace in d.run({'a':1,'b':2}):
=True)
trace.pprint(skip_passthrough d.output
Node square:
{'input': {'a': 2}, 'output': 4}
================================================================================
{'square_result': 4, 'init_a': 1}
If we dont supply a node that writes to a State, we can supply the state manually when running the diagram
with Define('Remember Input',type='decision') as D:
'square',Pow(2),inputs=[
V('Start(a=b)',is_b_even),
(
],=['End(square_result=.)'])
outputs'cube',Pow(3),inputs=['Start(a=b)'],outputs=['End(cube_result=.)'])
V(
'Start->State/init_a(0=a)')
E('End',inputs=['State/init_a(init_a=.)'])
V('End',inputs=['State/remember(remember=.)'])
V(
D.draw()=D()
dfor trace in d.run({'a':1,'b':2},state={'remember':100}):
=True)
trace.pprint(skip_passthrough d.output
Node square:
{'input': {'a': 2}, 'output': 4}
================================================================================
{'square_result': 4, 'init_a': 1, 'remember': 100}
Diagram State is a very powerful feature of stringdale. To learn more about the power of state, see the customizing state section.
Conditions made easier
Having to write funciton like checking if the key b
is even can get out of hand pretty fast.
To avoid having to write alot of small condition functions for different object configurations, stringdale’s standard library has a Condition
class that gives you the power of port mapping in your conditions.
from stringdale import Condition
= lambda x: x%2==0
is_even = lambda x: x<5
smaller_than_5
= Condition(is_even,mapping='x=b',name='is_b_even') is_b_even
with Define('Hello World Decision',type='decision') as D:
'square',Pow(2),inputs=[
V('Start(a=b)',is_b_even),
(
],=['End(square_result=.)'])
outputs'cube',Pow(3),inputs=['Start(a=b)'],outputs=['End(cube_result=.)'])
V(
D.draw()=D()
dfor trace in d.run({'a':1,'b':2}):
=True)
trace.pprint(skip_passthrough d.output
Node square:
{'input': {'a': 2}, 'output': 4}
================================================================================
{'square_result': 4}
Condition
has a bunch of other nice utilities, such as the ability to combine conditons via and
or or
= Condition(smaller_than_5,mapping='x=b',name='b_smaller_than_5')
b_smaller_than_5
= is_b_even & b_smaller_than_5
even_but_not_too_big even_but_not_too_big
(is_b_even & b_smaller_than_5)
with Define('Hello World Decision',type='decision') as D:
'square',Pow(2),inputs=[
V('Start(a=b)',even_but_not_too_big),
(
],=['End(square_result=.)'])
outputs'cube',Pow(3),inputs=['Start(a=b)'],outputs=['End(cube_result=.)'])
V( D.draw()
=D()
dfor trace in d.run({'a':1,'b':2}):
=True)
trace.pprint(skip_passthrough d.output
Node square:
{'input': {'a': 2}, 'output': 4}
================================================================================
{'square_result': 4}
=D()
dfor trace in d.run({'a':1,'b':10}):
=True)
trace.pprint(skip_passthrough d.output
Node cube:
{'input': {'a': 10}, 'output': 1000}
================================================================================
{'cube_result': 1000}
You can find out more about Condition
and other utilities in the utils section.
Example - Routing Agents
Here is an example of a LLM that monitors another LLM, prompting it until it returns a satisfactory response.
= Chat(model='gpt-4o-mini',
rhyming_agent =[{'role':'system','content':"""
messages Answer the following questions using rhyming words.
"""},
'role':'user','content':'{{question}}'},
{
],
)
await rhyming_agent(question='what is the capital of france?')
{'role': 'assistant',
'content': 'In Paris, the heart beats bright, a city of charm, a glorious sight.',
'meta': {'input_tokens': 129, 'output_tokens': 24}}
= Chat(model='gpt-4o-mini',
joke_agent =[{'role':'system','content':"""
messages Answer the following question with a joke.
"""},
'role':'user','content':'{{question}}'},
{
])
await joke_agent(question='what is the capital of france?')
{'role': 'assistant',
'content': 'Why did the French chef get arrested? Because he was caught beating the eggs in Paris!',
'meta': {'input_tokens': 127, 'output_tokens': 26}}
= Chat(model='gpt-4o-mini',
yo_mama_chat =[{'role':'system','content':"""
messages Answer the following question with a joke about the person's mother.
"""},
'role':'user','content':'{{question}}'},
{
])
await yo_mama_chat(question='what is the capital of france?')
{'role': 'assistant',
'content': 'Why did your mother go to Paris? Because she heard the Eiffel Tower was leaning towards her after all the times you forgot your mother’s birthday!',
'meta': {'input_tokens': 131, 'output_tokens': 36}}
= {
choice_descriptions 'rhyme':'this agent is good at rhyming',
'joke': 'this agent is good at telling jokes',
'yo_mama': 'this agent is specifically good at telling jokes about mothers'
}
= Chat(model='gpt-4o-mini',
router =[{'role':'system','content':"""
messages Choose the best sub-agent to answer the following question from among the following options:
{% for name,description in choice_descriptions.items() %}
- {{name}}: {{description}}
{% endfor %}
"""},
'role':'user','content':'{{question}}'},
{
],= choice_descriptions,
choice_descriptions =list(choice_descriptions.keys())
choices
)
router
Chat(model='gpt-4o-mini', required_keys={'question'}, choices=['rhyme', 'joke', 'yo_mama'], seed=42)
await router(question='tell me a joke about my mother')
{'role': 'assistant',
'content': 'yo_mama',
'meta': {'input_tokens': 198, 'output_tokens': 11}}
with Define('Router',type='decision') as Router:
'Start->state/q')
E('router',router,
V(=['Start(question=.)'],
inputs=[
outputs'rhyme(_)',),
('joke(_)',Condition('joke','(0=content)',name='content==joke')),
('yo_mama(_)',Condition('yo_mama','(0=content)',name='content==yo_mama')),
(
]
)'rhyme',rhyming_agent,
V(=['state/q(question=.)'],
inputs=['End']
outputs
)'joke',joke_agent,
V(=['state/q(question=.)'],
inputs=['End']
outputs
)'yo_mama',yo_mama_chat,
V(=['state/q(question=.)'],
inputs=['End']
outputs
) Router.draw()
= Router()
d for trace in d.run("what is the capital of france?, I like yo mama jokes"):
=False,skip_passthrough=True) trace.pprint(show_input
Node router:
{ 'output': { 'content': 'yo_mama',
'meta': {'input_tokens': 203, 'output_tokens': 11},
'role': 'assistant'}}
================================================================================
Node yo_mama:
{ 'output': { 'content': 'Yo mama is so lazy, she got a job at the Eiffel '
'Tower just so she could say she works from home!',
'meta': {'input_tokens': 136, 'output_tokens': 31},
'role': 'assistant'}}
================================================================================
In this example, we had to wire each sub agent manually, to see how this can be done generically in a DRY manner, see the keeping diagram DRY tutorial.
Breakpoint - interactive Diagrams
Getting feedback from the user mid flow can lead to very powerful agents. To enable this in stringdale, we can have breakpoint like nodes in Decision Diagrams. They can be defined like so:
= lambda x: x==42
is_42 def question_life(x):
return f'is {x} really the answer?'
def yes_man(message):
return f'Yes man, {message}'
with Define('Answer to Life with Feedback',type='decision') as D:
'add',add,
V(=['Start(**)'],
inputs=[
outputs'End',is_42),
('Reflect(x=.)'
])'Reflect',question_life,outputs=['GetFeedback'])
V(# we mark a node as a breakpoint via the is_break flag
'GetFeedback',yes_man,is_break=True,
V(=['End']
outputs
)
D.draw()
When we run a diagram, we actually run it either to the End, or the first breakpoint we encounter. We can check this via the finished
attribute of the diagram.
=D()
d1for trace in d1.run({'a':20,'b':22}):
=True)
trace.pprint(skip_passthrough
d1.finished,d1.output
Node add:
{'input': {'a': 20, 'b': 22}, 'output': 42}
================================================================================
(True, 42)
=D()
d2for trace in d2.run({'a':20,'b':21}):
=True)
trace.pprint(skip_passthrough
d2.finished,d2.output
Node add:
{'input': {'a': 20, 'b': 21}, 'output': 41}
================================================================================
Node Reflect:
{'input': {'x': 41}, 'output': 'is 41 really the answer?'}
================================================================================
(False, 'is 41 really the answer?')
running an unfinished diagram again will cause it to continue, while running a finished diagram again will cause it to restart.
Note: a finished diagram will retain state from previous runs. To get a new clean diagram instance, call the Schema again.
for trace in d2.run('43 is fine too'):
=True)
trace.pprint(skip_passthrough
d2.finished,d2.output
Node GetFeedback:
{'input': {0: '43 is fine too'}, 'output': 'Yes man, 43 is fine too'}
================================================================================
(True, 'Yes man, 43 is fine too')
for trace in d1.run({'a':7,'b':35}):
=True)
trace.pprint(skip_passthrough
d1.finished,d1.output
Node add:
{'input': {'a': 7, 'b': 35}, 'output': 42}
================================================================================
(True, 42)
We can use this pattern to communicate with the user in a stateful way.
Lets define a mock client, in practice, this will be your app’s logic
class Client:
def __init__(self,user_messages):
self.msg_queue = user_messages
def end_of_conversation(self):
return len(self.msg_queue)==0
def get_next_user_input(self):
= self.msg_queue.pop(0)
next_msg print(f'User: {next_msg}')
return next_msg
def send_answer(self,answer):
print(f'Assistant: {answer}')
def new_topic(self):
print('='*100)
= [
user_messages 'a':20,'b':22},
{'a':20,'b':23},
{f'43 is fine too',
'a':7,'b':35}
{
]= Client(user_messages)
client
=D()
dwhile not client.end_of_conversation():
= client.get_next_user_input()
next_input for trace in d.run(next_input):
pass
client.send_answer(d.output)if d.finished:
client.new_topic()
User: {'a': 20, 'b': 22}
Assistant: 42
====================================================================================================
User: {'a': 20, 'b': 23}
Assistant: is 43 really the answer?
User: 43 is fine too
Assistant: Yes man, 43 is fine too
====================================================================================================
User: {'a': 7, 'b': 35}
Assistant: 42
====================================================================================================
Example - collecting user info
In the following example we are trying to collect information about the user. However, since the user might not give us all the information in one go, we will want to ask followup questions until we have collected all the information.
Our agent will have two main steps:
- A chat that takes user input and parses the relvant information from it.
- A chat that sees what items are missing and phrases that as a request for the user.
Imagine we have the following user struct
from pydantic import BaseModel,Field
from typing import Optional
class User(BaseModel):
str] = Field(None, description='The name of the user')
name: Optional[int] = Field(None, description='The age of the user')
age: Optional[str] = Field(None, description='The email of the user') email: Optional[
= Chat(
ask_missing_data ='gpt-4o-mini',
model=[
messages'role':'system','content':'''
{ You are a helpful assistant asks the user for missing information.
Do not ask for keys if they are not a part of the missing keys
Ask the user for the following missing keys:
{{missing_keys}}
'''}])
= Chat(
format_new_info ='gpt-4o-mini',
model=[
messages'role':'system','content':'''
{ You are a helpful assistant that gets user data and makes sure it is complete.
If you are not sure that you were given the relevant information, put None in the relevant field.
Fill part of the missing keys based on the user data.
If the user didnt provide info an a given missing key, leave it empty
{% if current_info %}
The information we have so far is:
{{current_info}}
{% endif %}
The missing keys are:
{{missing_keys}}
'''},
'role':'user','content':'{{input}}'},
{
],=User) output_schema
Let make a function that keeps track of which keys are missing out of a pre-specified subset
class MissingKeys():
def __init__(self,keys):
self.keys = keys
def get_missing_keys(self,obj):
return [key for key in self.keys if getattr(obj,key,None) is None]
def has_missing_keys(self,obj):
return len(self.get_missing_keys(obj)) > 0
= MissingKeys(['name','age']) missing
='brian')) missing.get_missing_keys(User(name
['age']
with Define('collect user data',type='decision') as UserQA:
'Start',
V(=[
outputs'get_missing_keys',missing.has_missing_keys),
('End',),
('state/current_info',)
(
])
'get_missing_keys',missing.get_missing_keys,
V(=[
outputs'ask_missing_data(missing_keys)',
'state/missing_keys'
])
'ask_missing_data',ask_missing_data,
V(=['state/current_info(current_info)'],
inputs=['break'] )
outputs
'break',is_break=True,outputs=['format_new_info(input)'])
V(
'format_new_info',format_new_info,
V(=[
inputs'state/current_info(current_info)',
'state/missing_keys(missing_keys=.)'
],=[
outputs'get_missing_keys(0=content)',Condition(missing.has_missing_keys,'(0=content)')),
('End',
'state/current_info'
])
='TB') UserQA.draw(direction
= UserQA()
d
for trace in d.run(None):
trace.pprint() d.output
Node Start:
{'input': {0: None}, 'output': None}
================================================================================
Node get_missing_keys:
{'input': {0: None}, 'output': ['name', 'age']}
================================================================================
Node ask_missing_data:
{ 'input': {'current_info': None, 'missing_keys': ['name', 'age']},
'output': { 'content': 'Please provide your name and age.',
'meta': {'input_tokens': 151, 'output_tokens': 11},
'role': 'assistant'}}
================================================================================
{'role': 'assistant',
'content': 'Please provide your name and age.',
'meta': {'input_tokens': 151, 'output_tokens': 11}}
for trace in d.run('my name is brian'):
trace.pprint() d.output
Node break:
{'input': {0: 'my name is brian'}, 'output': 'my name is brian'}
================================================================================
Node format_new_info:
{ 'input': { 'current_info': None,
'input': 'my name is brian',
'missing_keys': ['name', 'age']},
'output': { 'content': User(name='brian', age=None, email=None),
'meta': {'input_tokens': 342, 'output_tokens': 22},
'role': 'assistant'}}
================================================================================
Node get_missing_keys:
{'input': {0: User(name='brian', age=None, email=None)}, 'output': ['age']}
================================================================================
Node ask_missing_data:
{ 'input': { 'current_info': { 'content': User(name='brian', age=None, email=None),
'meta': { 'input_tokens': 342,
'output_tokens': 22},
'role': 'assistant'},
'missing_keys': ['age']},
'output': { 'content': 'Please provide your age.',
'meta': {'input_tokens': 148, 'output_tokens': 9},
'role': 'assistant'}}
================================================================================
{'role': 'assistant',
'content': 'Please provide your age.',
'meta': {'input_tokens': 148, 'output_tokens': 9}}
for trace in d.run('25 yo'):
trace.pprint() d.output
Node break:
{'input': {0: '25 yo'}, 'output': '25 yo'}
================================================================================
Node format_new_info:
{ 'input': { 'current_info': { 'content': { 'age': None,
'email': None,
'name': 'brian'},
'meta': { 'input_tokens': 342,
'output_tokens': 22},
'role': 'assistant'},
'input': '25 yo',
'missing_keys': ['age']},
'output': { 'content': User(name='brian', age=25, email=None),
'meta': {'input_tokens': 388, 'output_tokens': 23},
'role': 'assistant'}}
================================================================================
Node End:
{ 'input': { 0: { 'content': User(name='brian', age=25, email=None),
'meta': {'input_tokens': 388, 'output_tokens': 23},
'role': 'assistant'}},
'output': { 'content': User(name='brian', age=25, email=None),
'meta': {'input_tokens': 388, 'output_tokens': 23},
'role': 'assistant'}}
================================================================================
{'role': 'assistant',
'content': User(name='brian', age=25, email=None),
'meta': {'input_tokens': 388, 'output_tokens': 23}}