planar 0.7.0__py3-none-any.whl → 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- planar/_version.py +1 -1
- planar/ai/agent.py +169 -318
- planar/ai/agent_base.py +166 -0
- planar/ai/agent_utils.py +4 -69
- planar/ai/models.py +30 -0
- planar/ai/pydantic_ai.py +86 -17
- planar/ai/test_agent_serialization.py +1 -1
- planar/app.py +1 -7
- planar/config.py +2 -0
- planar/data/__init__.py +17 -0
- planar/data/config.py +49 -0
- planar/data/dataset.py +272 -0
- planar/data/exceptions.py +19 -0
- planar/data/test_dataset.py +354 -0
- planar/dependencies.py +30 -0
- planar/routers/agents_router.py +52 -4
- planar/routers/test_agents_router.py +1 -1
- planar/routers/test_routes_security.py +3 -2
- planar/rules/__init__.py +12 -18
- planar/scaffold_templates/planar.dev.yaml.j2 +9 -0
- planar/scaffold_templates/planar.prod.yaml.j2 +14 -0
- planar/testing/workflow_observer.py +2 -2
- planar/workflows/notifications.py +39 -3
- {planar-0.7.0.dist-info → planar-0.9.0.dist-info}/METADATA +5 -1
- {planar-0.7.0.dist-info → planar-0.9.0.dist-info}/RECORD +27 -24
- planar/ai/providers.py +0 -1088
- planar/ai/pydantic_ai_agent.py +0 -329
- planar/ai/test_agent.py +0 -1298
- planar/ai/test_providers.py +0 -463
- {planar-0.7.0.dist-info → planar-0.9.0.dist-info}/WHEEL +0 -0
- {planar-0.7.0.dist-info → planar-0.9.0.dist-info}/entry_points.txt +0 -0
planar/ai/agent_base.py
ADDED
@@ -0,0 +1,166 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import abc
|
4
|
+
from dataclasses import dataclass, field
|
5
|
+
from typing import (
|
6
|
+
Any,
|
7
|
+
Callable,
|
8
|
+
Coroutine,
|
9
|
+
Type,
|
10
|
+
cast,
|
11
|
+
overload,
|
12
|
+
)
|
13
|
+
|
14
|
+
from pydantic import BaseModel
|
15
|
+
|
16
|
+
from planar.ai.models import AgentConfig, AgentEventEmitter, AgentRunResult
|
17
|
+
from planar.logging import get_logger
|
18
|
+
from planar.modeling.field_helpers import JsonSchema
|
19
|
+
from planar.utils import P, R, T, U
|
20
|
+
from planar.workflows import as_step
|
21
|
+
from planar.workflows.models import StepType
|
22
|
+
|
23
|
+
logger = get_logger(__name__)
|
24
|
+
|
25
|
+
|
26
|
+
@dataclass
|
27
|
+
class AgentBase[
|
28
|
+
# TODO: add `= str` default when we upgrade to 3.13
|
29
|
+
TInput: BaseModel | str,
|
30
|
+
TOutput: BaseModel | str,
|
31
|
+
](abc.ABC):
|
32
|
+
"""An LLM-powered agent that can be called directly within workflows."""
|
33
|
+
|
34
|
+
name: str
|
35
|
+
system_prompt: str
|
36
|
+
output_type: Type[TOutput] | None = None
|
37
|
+
input_type: Type[TInput] | None = None
|
38
|
+
user_prompt: str = ""
|
39
|
+
tools: list[Callable] = field(default_factory=list)
|
40
|
+
max_turns: int = 2
|
41
|
+
model_parameters: dict[str, Any] = field(default_factory=dict)
|
42
|
+
event_emitter: AgentEventEmitter | None = None
|
43
|
+
durable: bool = True
|
44
|
+
|
45
|
+
# TODO: move here to serialize to frontend
|
46
|
+
#
|
47
|
+
# built_in_vars: Dict[str, str] = field(default_factory=lambda: {
|
48
|
+
# "datetime_now": datetime.datetime.now().isoformat(),
|
49
|
+
# "date_today": datetime.date.today().isoformat(),
|
50
|
+
# })
|
51
|
+
|
52
|
+
def __post_init__(self):
|
53
|
+
if self.input_type:
|
54
|
+
if (
|
55
|
+
not issubclass(self.input_type, BaseModel)
|
56
|
+
and self.input_type is not str
|
57
|
+
):
|
58
|
+
raise ValueError(
|
59
|
+
"input_type must be 'str' or a subclass of a Pydantic model"
|
60
|
+
)
|
61
|
+
if self.max_turns < 1:
|
62
|
+
raise ValueError("Max_turns must be greater than or equal to 1.")
|
63
|
+
if self.tools and self.max_turns <= 1:
|
64
|
+
raise ValueError(
|
65
|
+
"For tool calling to work, max_turns must be greater than 1."
|
66
|
+
)
|
67
|
+
|
68
|
+
def input_schema(self) -> JsonSchema | None:
|
69
|
+
if self.input_type is None:
|
70
|
+
return None
|
71
|
+
if self.input_type is str:
|
72
|
+
return None
|
73
|
+
assert issubclass(self.input_type, BaseModel), (
|
74
|
+
"input_type must be a subclass of BaseModel or str"
|
75
|
+
)
|
76
|
+
return self.input_type.model_json_schema()
|
77
|
+
|
78
|
+
def output_schema(self) -> JsonSchema | None:
|
79
|
+
if self.output_type is None:
|
80
|
+
return None
|
81
|
+
if self.output_type is str:
|
82
|
+
return None
|
83
|
+
assert issubclass(self.output_type, BaseModel), (
|
84
|
+
"output_type must be a subclass of BaseModel or str"
|
85
|
+
)
|
86
|
+
return self.output_type.model_json_schema()
|
87
|
+
|
88
|
+
@overload
|
89
|
+
async def __call__(
|
90
|
+
self: "AgentBase[TInput, str]",
|
91
|
+
input_value: TInput,
|
92
|
+
) -> AgentRunResult[str]: ...
|
93
|
+
|
94
|
+
@overload
|
95
|
+
async def __call__(
|
96
|
+
self: "AgentBase[TInput, TOutput]",
|
97
|
+
input_value: TInput,
|
98
|
+
) -> AgentRunResult[TOutput]: ...
|
99
|
+
|
100
|
+
def as_step_if_durable(
|
101
|
+
self,
|
102
|
+
func: Callable[P, Coroutine[T, U, R]],
|
103
|
+
step_type: StepType,
|
104
|
+
display_name: str | None = None,
|
105
|
+
return_type: Type[R] | None = None,
|
106
|
+
) -> Callable[P, Coroutine[T, U, R]]:
|
107
|
+
if not self.durable:
|
108
|
+
return func
|
109
|
+
return as_step(
|
110
|
+
func,
|
111
|
+
step_type=step_type,
|
112
|
+
display_name=display_name or self.name,
|
113
|
+
return_type=return_type,
|
114
|
+
)
|
115
|
+
|
116
|
+
async def __call__(
|
117
|
+
self,
|
118
|
+
input_value: TInput,
|
119
|
+
) -> AgentRunResult[Any]:
|
120
|
+
if self.input_type is not None and not isinstance(input_value, self.input_type):
|
121
|
+
raise ValueError(
|
122
|
+
f"Input value must be of type {self.input_type}, but got {type(input_value)}"
|
123
|
+
)
|
124
|
+
elif not isinstance(input_value, (str, BaseModel)):
|
125
|
+
# Should not happen based on type constraints, but just in case
|
126
|
+
# user does not have type checking enabled
|
127
|
+
raise ValueError(
|
128
|
+
"Input value must be a string or a Pydantic model if input_type is not provided"
|
129
|
+
)
|
130
|
+
|
131
|
+
if self.output_type is None:
|
132
|
+
run_step = self.as_step_if_durable(
|
133
|
+
self.run_step,
|
134
|
+
step_type=StepType.AGENT,
|
135
|
+
display_name=self.name,
|
136
|
+
return_type=AgentRunResult[str],
|
137
|
+
)
|
138
|
+
else:
|
139
|
+
run_step = self.as_step_if_durable(
|
140
|
+
self.run_step,
|
141
|
+
step_type=StepType.AGENT,
|
142
|
+
display_name=self.name,
|
143
|
+
return_type=AgentRunResult[self.output_type],
|
144
|
+
)
|
145
|
+
|
146
|
+
result = await run_step(input_value=input_value)
|
147
|
+
# Cast the result to ensure type compatibility
|
148
|
+
return cast(AgentRunResult[TOutput], result)
|
149
|
+
|
150
|
+
@abc.abstractmethod
|
151
|
+
async def run_step(
|
152
|
+
self,
|
153
|
+
input_value: TInput,
|
154
|
+
) -> AgentRunResult[TOutput]: ...
|
155
|
+
|
156
|
+
@abc.abstractmethod
|
157
|
+
def get_model_str(self) -> str: ...
|
158
|
+
|
159
|
+
def to_config(self) -> AgentConfig:
|
160
|
+
return AgentConfig(
|
161
|
+
system_prompt=self.system_prompt,
|
162
|
+
user_prompt=self.user_prompt,
|
163
|
+
model=self.get_model_str(),
|
164
|
+
max_turns=self.max_turns,
|
165
|
+
model_parameters=self.model_parameters,
|
166
|
+
)
|
planar/ai/agent_utils.py
CHANGED
@@ -1,8 +1,4 @@
|
|
1
|
-
import asyncio
|
2
1
|
import inspect
|
3
|
-
import json
|
4
|
-
from collections.abc import AsyncGenerator
|
5
|
-
from enum import Enum
|
6
2
|
from typing import (
|
7
3
|
Any,
|
8
4
|
Callable,
|
@@ -20,77 +16,16 @@ from planar.ai.models import (
|
|
20
16
|
from planar.files.models import PlanarFile
|
21
17
|
from planar.logging import get_logger
|
22
18
|
from planar.object_config import ConfigurableObjectType, ObjectConfigurationIO
|
23
|
-
from planar.utils import utc_now
|
24
19
|
from planar.workflows import step
|
25
20
|
|
26
21
|
logger = get_logger(__name__)
|
27
22
|
|
28
23
|
|
29
|
-
class
|
30
|
-
"""
|
24
|
+
class ModelSpec(BaseModel):
|
25
|
+
"""Pydantic model for AI model specifications."""
|
31
26
|
|
32
|
-
|
33
|
-
|
34
|
-
COMPLETED = "completed"
|
35
|
-
ERROR = "error"
|
36
|
-
THINK = "think"
|
37
|
-
TEXT = "text"
|
38
|
-
|
39
|
-
|
40
|
-
class AgentEvent:
|
41
|
-
def __init__(
|
42
|
-
self,
|
43
|
-
event_type: AgentEventType,
|
44
|
-
data: BaseModel | str | None,
|
45
|
-
):
|
46
|
-
self.event_type = event_type
|
47
|
-
self.data = data
|
48
|
-
self.timestamp = utc_now().isoformat()
|
49
|
-
|
50
|
-
|
51
|
-
class AgentEventEmitter:
|
52
|
-
def __init__(self):
|
53
|
-
self.queue: asyncio.Queue[AgentEvent] = asyncio.Queue()
|
54
|
-
|
55
|
-
def emit(self, event_type: AgentEventType, data: BaseModel | str | None):
|
56
|
-
event = AgentEvent(event_type, data)
|
57
|
-
self.queue.put_nowait(event)
|
58
|
-
|
59
|
-
async def get_events(self) -> AsyncGenerator[str, None]:
|
60
|
-
while True:
|
61
|
-
event = await self.queue.get()
|
62
|
-
|
63
|
-
if isinstance(event.data, BaseModel):
|
64
|
-
data = {
|
65
|
-
"data": event.data.model_dump(),
|
66
|
-
"event_type": event.event_type,
|
67
|
-
}
|
68
|
-
else:
|
69
|
-
data = {
|
70
|
-
"data": event.data,
|
71
|
-
"event_type": event.event_type,
|
72
|
-
}
|
73
|
-
|
74
|
-
yield f"data: {json.dumps(data)}\n\n"
|
75
|
-
|
76
|
-
self.queue.task_done()
|
77
|
-
|
78
|
-
if event.event_type in (AgentEventType.COMPLETED, AgentEventType.ERROR):
|
79
|
-
break
|
80
|
-
|
81
|
-
def is_empty(self) -> bool:
|
82
|
-
"""Check if the queue is empty."""
|
83
|
-
return self.queue.empty()
|
84
|
-
|
85
|
-
|
86
|
-
# Define JsonData type as a union of valid JSON values
|
87
|
-
JsonData = str | int | float | bool | None | dict[str, Any] | list[Any]
|
88
|
-
|
89
|
-
|
90
|
-
class ToolCallResult(BaseModel):
|
91
|
-
tool_call_id: str
|
92
|
-
tool_call_name: str
|
93
|
-
content: BaseModel | JsonData
|
27
|
+
model_id: str
|
28
|
+
parameters: dict[str, Any] = {}
|
94
29
|
|
95
30
|
|
96
31
|
def extract_files_from_model(
|
planar/ai/models.py
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
+
from enum import Enum
|
3
4
|
from typing import (
|
4
5
|
Annotated,
|
5
6
|
Any,
|
@@ -7,6 +8,7 @@ from typing import (
|
|
7
8
|
List,
|
8
9
|
Literal,
|
9
10
|
Optional,
|
11
|
+
Protocol,
|
10
12
|
TypeVar,
|
11
13
|
Union,
|
12
14
|
)
|
@@ -88,10 +90,23 @@ class ToolMessage(ModelMessage):
|
|
88
90
|
tool_call_id: str # ID of the tool call this is responding to
|
89
91
|
|
90
92
|
|
93
|
+
# Define JsonData type as a union of valid JSON values
|
94
|
+
JsonData = str | int | float | bool | None | dict[str, Any] | list[Any]
|
95
|
+
|
96
|
+
|
97
|
+
class ToolCallResult(BaseModel):
|
98
|
+
tool_call_id: str
|
99
|
+
tool_call_name: str
|
100
|
+
content: BaseModel | JsonData
|
101
|
+
|
102
|
+
|
91
103
|
class CompletionResponse[T: BaseModel | str](BaseModel):
|
92
104
|
"""Response object that may contain content or tool calls."""
|
93
105
|
|
94
106
|
content: Optional[T] = None # Content as str or parsed Pydantic model
|
107
|
+
text_content: Optional[str] = (
|
108
|
+
None # Optional text content, if separate from structured output
|
109
|
+
)
|
95
110
|
reasoning_content: Optional[str] = None # Optional reasoning content
|
96
111
|
tool_calls: Optional[List[ToolCall]] = None # List of tool calls, if any
|
97
112
|
|
@@ -138,3 +153,18 @@ class AgentSerializeable(BaseModel):
|
|
138
153
|
|
139
154
|
# TODO: actually fetch built_in_vars from agent object
|
140
155
|
built_in_vars: dict[str, str] = Field(default_factory=dict)
|
156
|
+
|
157
|
+
|
158
|
+
class AgentEventType(str, Enum):
|
159
|
+
"""Valid event types that can be emitted by an Agent."""
|
160
|
+
|
161
|
+
RESPONSE = "response"
|
162
|
+
TOOL_RESPONSE = "tool_response"
|
163
|
+
COMPLETED = "completed"
|
164
|
+
ERROR = "error"
|
165
|
+
THINK = "think"
|
166
|
+
TEXT = "text"
|
167
|
+
|
168
|
+
|
169
|
+
class AgentEventEmitter(Protocol):
|
170
|
+
def emit(self, event_type: AgentEventType, data: BaseModel | str | None): ...
|
planar/ai/pydantic_ai.py
CHANGED
@@ -1,8 +1,9 @@
|
|
1
1
|
import base64
|
2
2
|
import json
|
3
|
+
import os
|
3
4
|
import re
|
4
5
|
import textwrap
|
5
|
-
from typing import Any,
|
6
|
+
from typing import Any, Type, cast
|
6
7
|
|
7
8
|
from pydantic import BaseModel, ValidationError
|
8
9
|
from pydantic_ai import BinaryContent
|
@@ -28,12 +29,13 @@ from pydantic_ai.messages import (
|
|
28
29
|
UserContent,
|
29
30
|
UserPromptPart,
|
30
31
|
)
|
31
|
-
from pydantic_ai.models import Model, ModelRequestParameters
|
32
|
+
from pydantic_ai.models import KnownModelName, Model, ModelRequestParameters
|
32
33
|
from pydantic_ai.settings import ModelSettings
|
33
34
|
from pydantic_ai.tools import ToolDefinition
|
34
35
|
from pydantic_core import ErrorDetails
|
35
36
|
|
36
37
|
from planar.ai import models as m
|
38
|
+
from planar.files.models import PlanarFile
|
37
39
|
from planar.logging import get_logger
|
38
40
|
from planar.utils import partition
|
39
41
|
|
@@ -67,7 +69,65 @@ def format_validation_errors(errors: list[ErrorDetails], function: bool) -> str:
|
|
67
69
|
return "\n".join(lines)
|
68
70
|
|
69
71
|
|
70
|
-
async def
|
72
|
+
async def openai_try_upload_file(
|
73
|
+
model: KnownModelName | Model, file: PlanarFile
|
74
|
+
) -> m.FileIdContent | None:
|
75
|
+
# Currently pydanticAI doesn't support passing file_ids, but leaving the
|
76
|
+
# implementation here for when they add support.
|
77
|
+
return None
|
78
|
+
|
79
|
+
if file.content_type != "application/pdf":
|
80
|
+
# old implementation only does this for pdf files, so keep the behavior for now
|
81
|
+
return None
|
82
|
+
|
83
|
+
if isinstance(model, str) and not model.startswith("openai:"):
|
84
|
+
# not using openai provider
|
85
|
+
return None
|
86
|
+
|
87
|
+
try:
|
88
|
+
# make this code work with openai as optional dependency
|
89
|
+
from pydantic_ai.models.openai import OpenAIModel
|
90
|
+
except ImportError:
|
91
|
+
return None
|
92
|
+
|
93
|
+
if os.getenv("OPENAI_BASE_URL", None) is not None:
|
94
|
+
# cannot use OpenAI file upload if using a custom base url
|
95
|
+
return None
|
96
|
+
|
97
|
+
if (
|
98
|
+
isinstance(model, OpenAIModel)
|
99
|
+
and model.client.base_url.host != "api.openai.com"
|
100
|
+
):
|
101
|
+
# same as above
|
102
|
+
return None
|
103
|
+
|
104
|
+
logger.debug("uploading pdf file to openai", filename=file.filename)
|
105
|
+
|
106
|
+
# use a separate AsyncClient instance since the model might be provided as a string
|
107
|
+
from openai import AsyncClient
|
108
|
+
|
109
|
+
client = AsyncClient()
|
110
|
+
|
111
|
+
# upload the file to the provider
|
112
|
+
openai_file = await client.files.create(
|
113
|
+
file=(
|
114
|
+
file.filename,
|
115
|
+
await file.get_content(),
|
116
|
+
file.content_type,
|
117
|
+
),
|
118
|
+
purpose="user_data",
|
119
|
+
)
|
120
|
+
logger.info(
|
121
|
+
"uploaded pdf file to openai",
|
122
|
+
filename=file.filename,
|
123
|
+
openai_file_id=openai_file.id,
|
124
|
+
)
|
125
|
+
return m.FileIdContent(content=openai_file.id)
|
126
|
+
|
127
|
+
|
128
|
+
async def build_file_map(
|
129
|
+
model: KnownModelName | Model, messages: list[m.ModelMessage]
|
130
|
+
) -> m.FileMap:
|
71
131
|
logger.debug("building file map", num_messages=len(messages))
|
72
132
|
file_dict = {}
|
73
133
|
|
@@ -86,6 +146,12 @@ async def build_file_map(messages: list[m.ModelMessage]) -> m.FileMap:
|
|
86
146
|
content_type=file.content_type,
|
87
147
|
)
|
88
148
|
|
149
|
+
file_content_id = await openai_try_upload_file(model, file)
|
150
|
+
# TODO: add more `try_upload_file` implementations for other providers that support
|
151
|
+
if file_content_id is not None:
|
152
|
+
file_dict[str(file.id)] = file_content_id
|
153
|
+
continue
|
154
|
+
|
89
155
|
# For now we are not using uploaded files with Gemini, so convert all to base64
|
90
156
|
if file.content_type.startswith(
|
91
157
|
("image/", "audio/", "video/", "application/pdf")
|
@@ -107,7 +173,9 @@ async def build_file_map(messages: list[m.ModelMessage]) -> m.FileMap:
|
|
107
173
|
return m.FileMap(mapping=file_dict)
|
108
174
|
|
109
175
|
|
110
|
-
async def prepare_messages(
|
176
|
+
async def prepare_messages(
|
177
|
+
model: KnownModelName | Model, messages: list[m.ModelMessage]
|
178
|
+
) -> list[Any]:
|
111
179
|
"""Prepare messages from Planar representations into the format expected by PydanticAI.
|
112
180
|
|
113
181
|
Args:
|
@@ -118,7 +186,7 @@ async def prepare_messages(messages: list[m.ModelMessage]) -> list[Any]:
|
|
118
186
|
List of messages in PydanticAI format
|
119
187
|
"""
|
120
188
|
pydantic_messages: list[ModelMessage] = []
|
121
|
-
file_map = await build_file_map(messages)
|
189
|
+
file_map = await build_file_map(model, messages)
|
122
190
|
|
123
191
|
def append_request_part(part: ModelRequestPart):
|
124
192
|
last = (
|
@@ -197,10 +265,6 @@ async def prepare_messages(messages: list[m.ModelMessage]) -> list[Any]:
|
|
197
265
|
return pydantic_messages
|
198
266
|
|
199
267
|
|
200
|
-
class StreamEventHandler(Protocol):
|
201
|
-
def emit(self, event: Literal["text", "think"], data: str) -> None: ...
|
202
|
-
|
203
|
-
|
204
268
|
def setup_native_structured_output(
|
205
269
|
request_params: ModelRequestParameters,
|
206
270
|
output_type: Type[BaseModel],
|
@@ -262,12 +326,14 @@ def return_native_structured_output[TOutput: BaseModel](
|
|
262
326
|
result = m.CompletionResponse(
|
263
327
|
content=output_type.model_validate_json(content),
|
264
328
|
tool_calls=final_tool_calls,
|
329
|
+
text_content=content,
|
265
330
|
reasoning_content=thinking,
|
266
331
|
)
|
267
332
|
logger.info(
|
268
333
|
"model run completed with structured output",
|
269
334
|
content=result.content,
|
270
335
|
reasoning_content=result.reasoning_content,
|
336
|
+
text_content=content,
|
271
337
|
tool_calls=result.tool_calls,
|
272
338
|
)
|
273
339
|
return result
|
@@ -291,6 +357,7 @@ def return_tool_structured_output[TOutput: BaseModel](
|
|
291
357
|
result = m.CompletionResponse(
|
292
358
|
content=output_type.model_validate(final_result_tc.arguments),
|
293
359
|
tool_calls=tool_calls,
|
360
|
+
text_content=content,
|
294
361
|
reasoning_content=thinking,
|
295
362
|
)
|
296
363
|
logger.info(
|
@@ -315,12 +382,12 @@ class ModelRunResponse[TOutput: BaseModel | str](BaseModel):
|
|
315
382
|
|
316
383
|
|
317
384
|
async def model_run[TOutput: BaseModel | str](
|
318
|
-
model: Model |
|
385
|
+
model: Model | KnownModelName,
|
319
386
|
max_extra_turns: int,
|
320
387
|
model_settings: dict[str, Any] | None = None,
|
321
388
|
messages: list[m.ModelMessage] = [],
|
322
389
|
tools: list[m.ToolDefinition] = [],
|
323
|
-
event_handler:
|
390
|
+
event_handler: m.AgentEventEmitter | None = None,
|
324
391
|
output_type: Type[TOutput] = str,
|
325
392
|
) -> ModelRunResponse[TOutput]:
|
326
393
|
# assert that the caller doesn't provide a tool called "final_result"
|
@@ -350,11 +417,11 @@ async def model_run[TOutput: BaseModel | str](
|
|
350
417
|
|
351
418
|
structured_output = issubclass(output_type, BaseModel)
|
352
419
|
|
353
|
-
def emit(event_type:
|
420
|
+
def emit(event_type: m.AgentEventType, content: str):
|
354
421
|
if event_handler:
|
355
422
|
event_handler.emit(event_type, content)
|
356
423
|
|
357
|
-
history = await prepare_messages(messages=messages)
|
424
|
+
history = await prepare_messages(model, messages=messages)
|
358
425
|
|
359
426
|
if structured_output:
|
360
427
|
if supports_native_structured_output:
|
@@ -383,10 +450,10 @@ async def model_run[TOutput: BaseModel | str](
|
|
383
450
|
case PartStartEvent(part=part):
|
384
451
|
response_parts.append(part)
|
385
452
|
if isinstance(part, TextPart):
|
386
|
-
emit(
|
453
|
+
emit(m.AgentEventType.TEXT, part.content)
|
387
454
|
text_buffer.append(part.content)
|
388
455
|
elif isinstance(part, ThinkingPart):
|
389
|
-
emit(
|
456
|
+
emit(m.AgentEventType.THINK, part.content)
|
390
457
|
think_buffer.append(part.content)
|
391
458
|
elif isinstance(part, ToolCallPart):
|
392
459
|
if current_tool_call is not None:
|
@@ -412,14 +479,14 @@ async def model_run[TOutput: BaseModel | str](
|
|
412
479
|
current = response_parts[-1]
|
413
480
|
if isinstance(delta, TextPartDelta):
|
414
481
|
assert isinstance(current, TextPart)
|
415
|
-
emit(
|
482
|
+
emit(m.AgentEventType.TEXT, delta.content_delta)
|
416
483
|
text_buffer.append(delta.content_delta)
|
417
484
|
current.content += delta.content_delta
|
418
485
|
elif (
|
419
486
|
isinstance(delta, ThinkingPartDelta) and delta.content_delta
|
420
487
|
):
|
421
488
|
assert isinstance(current, ThinkingPart)
|
422
|
-
emit(
|
489
|
+
emit(m.AgentEventType.THINK, delta.content_delta)
|
423
490
|
think_buffer.append(delta.content_delta)
|
424
491
|
current.content += delta.content_delta
|
425
492
|
elif isinstance(delta, ToolCallPartDelta):
|
@@ -479,6 +546,7 @@ async def model_run[TOutput: BaseModel | str](
|
|
479
546
|
return ModelRunResponse(
|
480
547
|
response=m.CompletionResponse(
|
481
548
|
tool_calls=final_tool_calls,
|
549
|
+
text_content=content,
|
482
550
|
reasoning_content=thinking,
|
483
551
|
),
|
484
552
|
extra_turns_used=extra_turns_used,
|
@@ -555,6 +623,7 @@ async def model_run[TOutput: BaseModel | str](
|
|
555
623
|
m.CompletionResponse(
|
556
624
|
content=content,
|
557
625
|
tool_calls=final_tool_calls,
|
626
|
+
text_content=content,
|
558
627
|
reasoning_content=thinking,
|
559
628
|
),
|
560
629
|
)
|
planar/app.py
CHANGED
@@ -37,7 +37,6 @@ from planar.security.authorization import PolicyService, policy_service_context
|
|
37
37
|
from planar.session import config_var, session_context
|
38
38
|
from planar.sse.proxy import SSEProxy
|
39
39
|
from planar.workflows import (
|
40
|
-
Workflow,
|
41
40
|
WorkflowNotification,
|
42
41
|
WorkflowNotificationCallback,
|
43
42
|
WorkflowOrchestrator,
|
@@ -169,13 +168,8 @@ class PlanarApp:
|
|
169
168
|
return
|
170
169
|
|
171
170
|
def on_workflow_notification(notification: WorkflowNotification):
|
172
|
-
workflow_id = (
|
173
|
-
notification.data.id
|
174
|
-
if isinstance(notification.data, Workflow)
|
175
|
-
else notification.data.workflow_id
|
176
|
-
)
|
177
171
|
self.sse_proxy.push(
|
178
|
-
f"{notification.kind.value}:{workflow_id}",
|
172
|
+
f"{notification.kind.value}:{notification.workflow_id}",
|
179
173
|
notification.data.model_dump(mode="json"),
|
180
174
|
)
|
181
175
|
|
planar/config.py
CHANGED
@@ -21,6 +21,7 @@ from pydantic import (
|
|
21
21
|
)
|
22
22
|
from sqlalchemy import URL, make_url
|
23
23
|
|
24
|
+
from planar.data.config import DataConfig
|
24
25
|
from planar.files.storage.config import LocalDirectoryConfig, StorageConfig
|
25
26
|
from planar.logging import get_logger
|
26
27
|
|
@@ -225,6 +226,7 @@ class PlanarConfig(BaseModel):
|
|
225
226
|
logging: dict[str, LoggerConfig] | None = None
|
226
227
|
use_alembic: bool | None = True
|
227
228
|
otel: OtelConfig | None = None
|
229
|
+
data: DataConfig | None = None
|
228
230
|
|
229
231
|
# forbid extra keys in the config to prevent accidental misconfiguration
|
230
232
|
model_config = ConfigDict(extra="forbid")
|
planar/data/__init__.py
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
from typing import TYPE_CHECKING
|
2
|
+
|
3
|
+
from planar.dependencies import lazy_exports
|
4
|
+
|
5
|
+
lazy_exports(
|
6
|
+
__name__,
|
7
|
+
{
|
8
|
+
"PlanarDataset": (".dataset", "PlanarDataset"),
|
9
|
+
},
|
10
|
+
)
|
11
|
+
|
12
|
+
if TYPE_CHECKING:
|
13
|
+
from .dataset import PlanarDataset
|
14
|
+
|
15
|
+
__all__ = [
|
16
|
+
"PlanarDataset",
|
17
|
+
]
|
planar/data/config.py
ADDED
@@ -0,0 +1,49 @@
|
|
1
|
+
"""Configuration for Planar data module."""
|
2
|
+
|
3
|
+
from typing import Annotated, Literal
|
4
|
+
|
5
|
+
from pydantic import BaseModel, Field
|
6
|
+
|
7
|
+
from planar.files.storage.config import StorageConfig
|
8
|
+
|
9
|
+
|
10
|
+
class DuckDBCatalogConfig(BaseModel):
|
11
|
+
"""Configuration for DuckDB catalog backend."""
|
12
|
+
|
13
|
+
type: Literal["duckdb"]
|
14
|
+
path: str # Path to .ducklake file
|
15
|
+
|
16
|
+
|
17
|
+
class PostgresCatalogConfig(BaseModel):
|
18
|
+
"""Configuration for PostgreSQL catalog backend."""
|
19
|
+
|
20
|
+
type: Literal["postgres"]
|
21
|
+
host: str | None = None
|
22
|
+
port: int | None = None
|
23
|
+
user: str | None = None
|
24
|
+
password: str | None = None
|
25
|
+
db: str
|
26
|
+
|
27
|
+
|
28
|
+
class SQLiteCatalogConfig(BaseModel):
|
29
|
+
"""Configuration for SQLite catalog backend."""
|
30
|
+
|
31
|
+
type: Literal["sqlite"]
|
32
|
+
path: str # Path to .sqlite file
|
33
|
+
|
34
|
+
|
35
|
+
# Discriminated union for catalog configurations
|
36
|
+
CatalogConfig = Annotated[
|
37
|
+
DuckDBCatalogConfig | PostgresCatalogConfig | SQLiteCatalogConfig,
|
38
|
+
Field(discriminator="type"),
|
39
|
+
]
|
40
|
+
|
41
|
+
|
42
|
+
class DataConfig(BaseModel):
|
43
|
+
"""Configuration for data features."""
|
44
|
+
|
45
|
+
catalog: CatalogConfig
|
46
|
+
storage: StorageConfig # Reuse existing StorageConfig from files
|
47
|
+
|
48
|
+
# Optional settings
|
49
|
+
catalog_name: str = "planar_data" # Default catalog name in Ducklake
|