Repository: PrefectHQ/ControlFlow

Issue:
Support langchain 0.3
### Enhancement Description

LangChain 0.3 finally adds Pydantic 2 support, so we can clean up the backend significantly

### Use Case

_No response_

### Proposed Implementation

_No response_

Discussion:

Patch:
diff --git a/src/controlflow/agents/agent.py b/src/controlflow/agents/agent.py
index d4833b2..3e9890c 100644
--- a/src/controlflow/agents/agent.py
+++ b/src/controlflow/agents/agent.py
@@ -78,9 +78,7 @@ class Agent(ControlFlowModel, abc.ABC):
         exclude=True,
     )
 
-    # note: `model` should be typed as Optional[BaseChatModel] but V2 models can't have
-    # V1 attributes without erroring, so we have to use Any.
-    model: Optional[Union[str, Any]] = Field(
+    model: Optional[Union[str, BaseChatModel]] = Field(
         None,
         description="The LangChain BaseChatModel used by the agent. If not provided, the default model will be used. A compatible string can be passed to automatically retrieve the model.",
         exclude=True,
@@ -133,7 +131,7 @@ class Agent(ControlFlowModel, abc.ABC):
         return as_tools(tools or [])
 
     @field_validator("model", mode="before")
-    def _validate_model(cls, model: Optional[Union[str, Any]]):
+    def _validate_model(cls, model: Optional[Union[str, BaseChatModel]]):
         if isinstance(model, str):
             return get_model_from_string(model)
         return model
@@ -141,7 +139,6 @@ class Agent(ControlFlowModel, abc.ABC):
     @field_serializer("tools")
     def _serialize_tools(self, tools: list[Tool]):
         tools = controlflow.tools.as_tools(tools)
-        # tools are Pydantic 1 objects
         return [t.model_dump(include={"name", "description"}) for t in tools]
 
     def serialize_for_prompt(self) -> dict:
diff --git a/src/controlflow/defaults.py b/src/controlflow/defaults.py
index 5320429..064a8cf 100644
--- a/src/controlflow/defaults.py
+++ b/src/controlflow/defaults.py
@@ -40,12 +40,13 @@ class Defaults(ControlFlowModel):
         fields = ", ".join(self.model_fields.keys())
         return f"<ControlFlow Defaults: {fields}>"
 
-    @field_validator("model")
+    @field_validator("model", mode="before")
     def _model(cls, v):
         if isinstance(v, str):
             v = get_model(v)
-        elif v is not None and not isinstance(v, BaseChatModel):
-            raise ValueError("Input must be an instance of BaseChatModel")
+        # the model validator in langchain forcibly expects a dictionary
+        elif v is not None and not isinstance(v, (dict, BaseChatModel)):
+            raise ValueError("Input must be an instance of dict or BaseChatModel")
         return v
 
 
diff --git a/src/controlflow/tools/tools.py b/src/controlflow/tools/tools.py
index 683f09e..b9653f7 100644
--- a/src/controlflow/tools/tools.py
+++ b/src/controlflow/tools/tools.py
@@ -6,7 +6,6 @@ from typing import Annotated, Any, Callable, Optional, Union
 
 import langchain_core.tools
 import pydantic
-import pydantic.v1
 from langchain_core.messages import InvalidToolCall, ToolCall
 from prefect.utilities.asyncutils import run_coro_as_sync
 from pydantic import Field, PydanticSchemaGenerationError, TypeAdapter


Test patch:
diff --git a/tests/test_defaults.py b/tests/test_defaults.py
index 98ec5bf..4b6bf55 100644
--- a/tests/test_defaults.py
+++ b/tests/test_defaults.py
@@ -9,7 +9,8 @@ import controlflow.llm
 
 def test_default_model_failed_validation():
     with pytest.raises(
-        pydantic.ValidationError, match="Input must be an instance of BaseChatModel"
+        pydantic.ValidationError,
+        match="Input must be an instance of dict or BaseChatModel",
     ):
         controlflow.defaults.model = 5
 
diff --git a/tests/tools/test_lc_tools.py b/tests/tools/test_lc_tools.py
index 58d5220..3e38084 100644
--- a/tests/tools/test_lc_tools.py
+++ b/tests/tools/test_lc_tools.py
@@ -2,7 +2,7 @@ from unittest.mock import MagicMock
 
 from langchain_community.tools import DuckDuckGoSearchRun
 from langchain_core.tools import BaseTool
-from pydantic.v1 import BaseModel
+from pydantic import BaseModel
 
 import controlflow
 from controlflow.events.events import AIMessage, ToolCall