Skip to content

Commit 37f9547

Browse files
committed
add configurable eager mode
1 parent 6264089 commit 37f9547

File tree

5 files changed

+160
-34
lines changed

5 files changed

+160
-34
lines changed

requirements-dev.lock

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -107,12 +107,8 @@ distlib==0.3.8
107107
# via virtualenv
108108
distro==1.9.0
109109
# via openai
110-
dnspython==2.6.1
111-
# via email-validator
112110
docker==6.1.3
113111
# via prefect
114-
email-validator==2.1.1
115-
# via pydantic
116112
envier==0.5.1
117113
# via ddtrace
118114
execnet==2.1.1
@@ -160,7 +156,6 @@ identify==2.5.35
160156
# via pre-commit
161157
idna==3.6
162158
# via anyio
163-
# via email-validator
164159
# via httpx
165160
# via requests
166161
importlib-metadata==7.0.0
@@ -220,7 +215,7 @@ markupsafe==2.1.5
220215
# via mkdocs-autorefs
221216
# via mkdocstrings
222217
# via werkzeug
223-
marvin @ git+https://github.com/prefecthq/marvin@aedfb9573e5f0844a1dc1b45b59d279524b0d363
218+
marvin @ git+https://github.com/prefecthq/marvin@fbfa2e6d2f8f65d611f3519941966e7fc382a880
224219
# via controlflow
225220
matplotlib-inline==0.1.6
226221
# via ipython
@@ -301,7 +296,7 @@ pluggy==1.4.0
301296
# via pytest
302297
pre-commit==3.7.0
303298
# via prefect
304-
prefect @ git+https://github.com/prefecthq/prefect@8454a1c8938bdd1204eefbdb3606129e37501653
299+
prefect @ git+https://github.com/prefecthq/prefect@cae0efd9d667ca8b2003e061c5041619a4c5a881
305300
# via controlflow
306301
prompt-toolkit==3.0.43
307302
# via ipython
@@ -449,6 +444,7 @@ s3transfer==0.10.1
449444
setuptools==69.2.0
450445
# via ddtrace
451446
# via nodeenv
447+
# via prefect
452448
# via readchar
453449
shellingham==1.5.4
454450
# via typer-slim

requirements.lock

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -107,12 +107,8 @@ distlib==0.3.8
107107
# via virtualenv
108108
distro==1.9.0
109109
# via openai
110-
dnspython==2.6.1
111-
# via email-validator
112110
docker==6.1.3
113111
# via prefect
114-
email-validator==2.1.1
115-
# via pydantic
116112
envier==0.5.1
117113
# via ddtrace
118114
execnet==2.1.1
@@ -160,7 +156,6 @@ identify==2.5.36
160156
# via pre-commit
161157
idna==3.6
162158
# via anyio
163-
# via email-validator
164159
# via httpx
165160
# via requests
166161
importlib-metadata==7.0.0
@@ -220,7 +215,7 @@ markupsafe==2.1.5
220215
# via mkdocs-autorefs
221216
# via mkdocstrings
222217
# via werkzeug
223-
marvin @ git+https://github.com/prefecthq/marvin@aedfb9573e5f0844a1dc1b45b59d279524b0d363
218+
marvin @ git+https://github.com/prefecthq/marvin@fbfa2e6d2f8f65d611f3519941966e7fc382a880
224219
# via controlflow
225220
matplotlib-inline==0.1.7
226221
# via ipython
@@ -301,7 +296,7 @@ pluggy==1.5.0
301296
# via pytest
302297
pre-commit==3.7.1
303298
# via prefect
304-
prefect @ git+https://github.com/prefecthq/prefect@8454a1c8938bdd1204eefbdb3606129e37501653
299+
prefect @ git+https://github.com/prefecthq/prefect@cae0efd9d667ca8b2003e061c5041619a4c5a881
305300
# via controlflow
306301
prompt-toolkit==3.0.43
307302
# via ipython
@@ -449,6 +444,7 @@ s3transfer==0.10.1
449444
setuptools==69.2.0
450445
# via ddtrace
451446
# via nodeenv
447+
# via prefect
452448
# via readchar
453449
shellingham==1.5.4
454450
# via typer-slim

src/controlflow/decorators.py

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ def flow(
2424
instructions: str = None,
2525
tools: list[ToolType] = None,
2626
agents: list["Agent"] = None,
27-
resolve_results: bool = None,
27+
eager: bool = None,
2828
):
2929
"""
3030
A decorator that wraps a function as a ControlFlow flow.
@@ -43,7 +43,7 @@ def flow(
4343
instructions (str, optional): Instructions for the flow. Defaults to None.
4444
tools (list[ToolType], optional): List of tools to be used in the flow. Defaults to None.
4545
agents (list[Agent], optional): List of agents to be used in the flow. Defaults to None.
46-
resolve_results (bool, optional): Whether to resolve the results of tasks. Defaults to True.
46+
eager (bool, optional): Whether the flow should be run eagerly. Defaults to the global setting (True)
4747
4848
Returns:
4949
callable: The wrapped function or a new flow decorator if `fn` is not provided.
@@ -57,11 +57,12 @@ def flow(
5757
instructions=instructions,
5858
tools=tools,
5959
agents=agents,
60-
resolve_results=resolve_results,
60+
eager=eager,
6161
)
6262

63-
if resolve_results is None:
64-
resolve_results = True
63+
if eager is None:
64+
eager = controlflow.settings.eager_mode
65+
6566
sig = inspect.signature(fn)
6667

6768
@functools.wraps(fn)
@@ -92,20 +93,22 @@ def wrapper(
9293

9394
# create a function to wrap as a Prefect flow
9495
@prefect.flow
95-
def wrapped_flow(*args, **kwargs):
96+
def wrapped_flow(*args, eager_=None, **kwargs):
9697
with flow_obj, patch_marvin():
9798
with controlflow.instructions(instructions):
9899
result = fn(*args, **kwargs)
99100

100-
if resolve_results:
101+
# allow runtime override of eager mode
102+
eager_mode = eager_ if eager_ is not None else eager
103+
if eager_mode:
101104
# resolve any returned tasks; this will raise on failure
102105
result = resolve_tasks(result)
103106

104-
# run all tasks in the flow to completion
105-
Controller(
106-
flow=flow_obj,
107-
tasks=list(flow_obj._tasks.values()),
108-
).run()
107+
# run all tasks in the flow to completion
108+
Controller(
109+
flow=flow_obj,
110+
tasks=list(flow_obj._tasks.values()),
111+
).run()
109112

110113
return result
111114

@@ -126,13 +129,16 @@ def task(
126129
agents: list["Agent"] = None,
127130
tools: list[ToolType] = None,
128131
user_access: bool = None,
132+
eager: bool = None,
129133
):
130134
"""
131135
A decorator that turns a Python function into a Task. The Task objective is
132136
set to the function name, and the instructions are set to the function
133-
docstring. When the function is called, the arguments are provided to the
134-
task as context, and the task is run to completion. If successful, the task
135-
result is returned; if failed, an error is raised.
137+
docstring. When the function is called in eager mode, the arguments are
138+
provided to the task as context, and the task is run to completion. If
139+
successful, the task result is returned; if failed, an error is raised. When
140+
the function is called with eager mode set to False, a Task object is
141+
returned which can be run later.
136142
137143
Args:
138144
fn (callable, optional): The function to be wrapped as a task. If not provided,
@@ -145,6 +151,7 @@ def task(
145151
tools (list[ToolType], optional): List of tools to be used in the task. Defaults to None.
146152
user_access (bool, optional): Whether the task requires user access. Defaults to None,
147153
in which case it is set to False.
154+
eager (bool, optional): Whether the task should be run eagerly. Defaults to the global setting (True)
148155
149156
Returns:
150157
callable: The wrapped function or a new task decorator if `fn` is not provided.
@@ -158,6 +165,7 @@ def task(
158165
agents=agents,
159166
tools=tools,
160167
user_access=user_access,
168+
eager=eager,
161169
)
162170

163171
sig = inspect.signature(fn)
@@ -168,8 +176,13 @@ def task(
168176
if instructions is None:
169177
instructions = fn.__doc__
170178

179+
if eager is None:
180+
eager = controlflow.settings.eager_mode
181+
182+
result_type = fn.__annotations__.get("return")
183+
171184
@functools.wraps(fn)
172-
def wrapper(*args, **kwargs):
185+
def wrapper(*args, eager_: bool = None, **kwargs):
173186
# first process callargs
174187
bound = sig.bind(*args, **kwargs)
175188
bound.apply_defaults()
@@ -179,12 +192,20 @@ def wrapper(*args, **kwargs):
179192
instructions=instructions,
180193
agents=agents,
181194
context=bound.arguments,
182-
result_type=fn.__annotations__.get("return"),
195+
result_type=result_type,
183196
user_access=user_access or False,
184197
tools=tools or [],
185198
)
186199

187-
task.run()
188-
return task.result
200+
# allow runtime override of eager mode
201+
eager_mode = eager_ if eager_ is not None else eager
202+
if eager_mode:
203+
task.run()
204+
return task.result
205+
else:
206+
return task
207+
208+
if eager is False:
209+
wrapper.__annotations__["return"] = Task
189210

190211
return wrapper

src/controlflow/settings.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ class Settings(ControlFlowSettings):
6060
description="If True, a global flow is created for convenience, so users don't have to wrap every invocation in a flow function. Disable to avoid accidentally sharing context between agents.",
6161
)
6262
openai_api_key: Optional[str] = Field(None, validate_assignment=True)
63+
eager_mode: bool = Field(
64+
True,
65+
description="If True, @task- and @flow-decorated functions are run immediately. "
66+
"This can be set on a per-task or per-flow basis using the `eager` argument.",
67+
)
6368

6469
def __init__(self, **data):
6570
super().__init__(**data)

tests/test_decorators.py

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import controlflow
12
import pytest
23
from controlflow import Task
3-
from controlflow.decorators import flow
4+
from controlflow.decorators import flow, task
5+
from controlflow.settings import temporary_settings
46

57

68
@pytest.mark.usefixtures("mock_controller")
@@ -52,3 +54,109 @@ def test_flow():
5254

5355
result = test_flow()
5456
assert result == "hello"
57+
58+
59+
class TestTaskDecorator:
60+
pass
61+
62+
63+
@pytest.mark.usefixtures("mock_controller")
64+
class TestTaskEagerMode:
65+
def test_eager_mode_enabled_by_default(self):
66+
assert controlflow.settings.eager_mode is True
67+
68+
def test_task_eager_mode(self, mock_controller_run_agent):
69+
@task
70+
def return_42() -> int:
71+
"""Return the number 42"""
72+
pass
73+
74+
return_42()
75+
assert mock_controller_run_agent.call_count == 1
76+
77+
def test_task_eager_mode_off(self, mock_controller_run_agent):
78+
@task(eager=False)
79+
def return_42() -> int:
80+
"""Return the number 42"""
81+
pass
82+
83+
result = return_42()
84+
assert mock_controller_run_agent.call_count == 0
85+
assert isinstance(result, Task)
86+
assert result.objective == "return_42"
87+
assert result.result_type == int
88+
assert result.instructions == "Return the number 42"
89+
90+
def test_task_eager_mode_loads_default(self, mock_controller_run_agent):
91+
with temporary_settings(eager_mode=False):
92+
93+
@task
94+
def return_42() -> int:
95+
"""Return the number 42"""
96+
pass
97+
98+
result = return_42()
99+
assert mock_controller_run_agent.call_count == 0
100+
assert isinstance(result, Task)
101+
assert result.objective == "return_42"
102+
assert result.result_type == int
103+
assert result.instructions == "Return the number 42"
104+
105+
@pytest.mark.parametrize("eager_mode", [True, False])
106+
def test_override_eager_mode_at_call_time(
107+
self, mock_controller_run_agent, eager_mode
108+
):
109+
with temporary_settings(eager_mode=eager_mode):
110+
111+
@task
112+
def return_42() -> int:
113+
"""Return the number 42"""
114+
pass
115+
116+
return_42(eager_=not eager_mode)
117+
if eager_mode:
118+
assert mock_controller_run_agent.call_count == 0
119+
else:
120+
assert mock_controller_run_agent.call_count == 1
121+
122+
123+
@pytest.mark.usefixtures("mock_controller")
124+
class TestFlowEagerMode:
125+
def test_flow_eager_mode(self, mock_controller_run_agent):
126+
@flow
127+
def test_flow():
128+
task = Task("say hello", result="hello")
129+
return task
130+
131+
result = test_flow()
132+
assert mock_controller_run_agent.call_count == 1
133+
assert result == "hello"
134+
135+
def test_flow_eager_mode_off(self, mock_controller_run_agent):
136+
@flow(eager=False)
137+
def test_flow():
138+
task = Task("say hello", result="hello")
139+
return task
140+
141+
result = test_flow()
142+
assert mock_controller_run_agent.call_count == 0
143+
assert isinstance(result, Task)
144+
assert result.objective == "say hello"
145+
assert result.result == "hello"
146+
147+
def test_flow_eager_mode_off_doesnt_affect_tasks_with_eager_mode_on(
148+
self, mock_controller_run_agent
149+
):
150+
@task(eager=True)
151+
def return_42() -> int:
152+
"""Return the number 42"""
153+
pass
154+
155+
@flow(eager=False)
156+
def test_flow():
157+
result = return_42()
158+
return result
159+
160+
result = test_flow()
161+
assert mock_controller_run_agent.call_count == 1
162+
assert not isinstance(result, Task)

0 commit comments

Comments
 (0)