diff --git a/examples/reasoning.py b/examples/reasoning.py new file mode 100644 index 00000000..841baee3 --- /dev/null +++ b/examples/reasoning.py @@ -0,0 +1,118 @@ +""" +This example implements a reasoning loop that lets a relatively simple model +solve difficult problems. + +Here, gpt-4o-mini is used to solve a problem that typically requires o1's +reasoning ability. +""" + +import argparse + +from pydantic import BaseModel, Field + +import controlflow as cf +from controlflow.utilities.general import unwrap + + +class ReasoningStep(BaseModel): + explanation: str = Field( + description=""" + A brief (<5 words) description of what you intend to + achieve in this step, to display to the user. + """ + ) + reasoning: str = Field( + description="A single step of reasoning, not more than 1 or 2 sentences." + ) + found_validated_solution: bool + + +@cf.flow +def solve_with_reasoning(goal: str, agent: cf.Agent) -> str: + while True: + response: ReasoningStep = cf.run( + objective=""" + Carefully read the `goal` and analyze the problem. + + Produce a single step of reasoning that advances you closer to a solution. + """, + instructions=""" + You are working on solving a difficult problem (the `goal`). Based + on your previous thoughts and the overall goal, please perform **one + reasoning step** that advances you closer to a solution. Document + your thought process and any intermediate steps you take. + + After marking this task complete for a single step, you will be + given a new reasoning task to continue working on the problem. The + loop will continue until you have a valid solution. + + Complete the task as soon as you have a valid solution. + + **Guidelines** + + - You will not be able to brute force a solution exhaustively. You + must use your reasoning ability to make a plan that lets you make + progress. + - Each step should be focused on a specific aspect of the problem, + either advancing your understanding of the problem or validating a + solution. + - You should build on previous steps without repeating them. + - Since you will iterate your reasoning, you can explore multiple + approaches in different steps. + - Use logical and analytical thinking to reason through the problem. + - Ensure that your solution is valid and meets all requirements. + - If you find yourself spinning your wheels, take a step back and + re-evaluate your approach. + + """, + result_type=ReasoningStep, + agents=[agent], + context=dict(goal=goal), + model_kwargs=dict(tool_choice="required"), + ) + + if response.found_validated_solution: + if cf.run( + """ + Check your solution to be absolutely sure that it is correct and meets + all requirements of the goal. If you return True, the loop will end. If you + return False, you will be able to continue reasoning. + """, + result_type=bool, + context=dict(goal=goal), + ): + break + + return cf.run(objective=goal, agents=[agent]) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Solve a reasoning problem.") + parser.add_argument("--goal", type=str, help="Custom goal to solve", default=None) + args = parser.parse_args() + + agent = cf.Agent(name="Definitely not GPT-4o mini", model="openai/gpt-4o-mini") + + # Default goal via https://www.reddit.com/r/singularity/comments/1fggo1e/comment/ln3ymsu/ + default_goal = """ + Using only four instances of the digit 9 and any combination of the following + mathematical operations: the decimal point, parentheses, addition (+), + subtraction (-), multiplication (*), division (/), factorial (!), and square + root (sqrt), create an equation that equals 24. + + In order to validate your result, you should test that you have followed the rules: + + 1. You have used the correct number of variables + 2. You have only used 9s and potentially a leading 0 for a decimal + 3. You have used valid mathematical symbols + 4. Your equation truly equates to 24. + """ + + # Use the provided goal if available, otherwise use the default + goal = args.goal if args.goal is not None else default_goal + goal = unwrap(goal) + print(f"The goal is:\n\n{goal}") + + result = solve_with_reasoning(goal=goal, agent=agent) + + print(f"The solution is:\n\n{result}") diff --git a/src/controlflow/flows/graph.py b/src/controlflow/flows/graph.py index 2c2af8df..d200958a 100644 --- a/src/controlflow/flows/graph.py +++ b/src/controlflow/flows/graph.py @@ -73,7 +73,7 @@ def add_task(self, task: Task): ) # add the task's subtasks - for subtask in task._subtasks: + for subtask in task.subtasks: self.add_edge( Edge( upstream=subtask, diff --git a/src/controlflow/orchestration/prompt_templates/task.jinja b/src/controlflow/orchestration/prompt_templates/task.jinja index 7ee1efb8..23fb868f 100644 --- a/src/controlflow/orchestration/prompt_templates/task.jinja +++ b/src/controlflow/orchestration/prompt_templates/task.jinja @@ -4,7 +4,9 @@ {% if task.instructions %}- instructions: {{ task.instructions }}{% endif %} {% if task.result_type %}- result type: {{ task.result_type }}{% endif %} {% if task.context %}- context: {{ task.context }}{% endif %} -- can be completed before subtasks: {{ not task.wait_for_subtasks }} -{% if task.depends_on %}- depends on: {{ task.depends_on | map(attribute='id') | join(', ') }}{% endif %} -{% if task.parent %}- parent task: {{ task.parent.id }}{% endif %} -{% if task._subtasks%}- subtasks: {{ task._subtasks | map(attribute='id') | join(', ') }}{% endif %} \ No newline at end of file +{% if task.parent %}- parent task ID: {{ task.parent.id }}{%endif %} +{% if task._subtasks%}- this task has the following subtask IDs: {{ task._subtasks | map(attribute='id') | join(', ') }} +{% if not task.wait_for_subtasks %}- complete this task as soon as you meet its objective, even if you haven't completed +its subtasks{% endif%}{% endif %} +{% if task.depends_on %}- this task depends on these upstream task IDs (includes subtasks): {{ task.depends_on | +map(attribute='id') | join(', ') }}{% endif %} \ No newline at end of file diff --git a/src/controlflow/orchestration/prompt_templates/tasks.jinja b/src/controlflow/orchestration/prompt_templates/tasks.jinja index 5dc5dd1f..36868957 100644 --- a/src/controlflow/orchestration/prompt_templates/tasks.jinja +++ b/src/controlflow/orchestration/prompt_templates/tasks.jinja @@ -36,4 +36,16 @@ it. A task can only be marked complete one time. Do not attempt to mark a task successful more than once. Even if the `result_type` does not appear to match the objective, you must supply a single compatible result. Only mark a task -failed if there is a technical error or issue preventing completion. \ No newline at end of file +failed if there is a technical error or issue preventing completion. + +When a parent task must wait for subtasks, it means that all of its subtasks are +treated as upstream dependencies and must be completed before the parent task +can be marked as complete. However, if the parent task has +`wait_for_subtasks=False`, then it can and should be marked as complete as soon +as you can, regardless of the status of its subtasks. + +## Subtask hierarchy + +{% for task in task_hierarchy %} +{{ render_task_hierarchy(task) }} +{% endfor %} \ No newline at end of file diff --git a/src/controlflow/tasks/task.py b/src/controlflow/tasks/task.py index 760e6459..a0560923 100644 --- a/src/controlflow/tasks/task.py +++ b/src/controlflow/tasks/task.py @@ -107,8 +107,7 @@ class Task(ControlFlowModel): ) context: dict = Field( default_factory=dict, - description="Additional context for the task. If tasks are provided as " - "context, they are automatically added as `depends_on`", + description="Additional context for the task.", ) parent: Optional["Task"] = Field( NOTSET, @@ -259,9 +258,16 @@ def __eq__(self, other): if type(self) is type(other): d1 = dict(self) d2 = dict(other) + + for attr in ["id", "created_at"]: + d1.pop(attr) + d2.pop(attr) + # conver sets to lists for comparison d1["depends_on"] = list(d1["depends_on"]) d2["depends_on"] = list(d2["depends_on"]) + d1["subtasks"] = list(self.subtasks) + d2["subtasks"] = list(other.subtasks) return d1 == d2 return False @@ -375,7 +381,6 @@ def add_subtask(self, task: "Task"): elif task.parent is not self: raise ValueError(f"{self.friendly_name()} already has a parent.") self._subtasks.add(task) - self.depends_on.add(task) def add_dependency(self, task: "Task"): """ @@ -489,8 +494,8 @@ def is_ready(self) -> bool: incomplete, meaning it is ready to be worked on. """ depends_on = self.depends_on - if not self.wait_for_subtasks: - depends_on = depends_on.difference(self._subtasks) + if self.wait_for_subtasks: + depends_on = depends_on.union(self._subtasks) return self.is_incomplete() and all(t.is_complete() for t in depends_on)