polos-sdk 0.1.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.
- polos/__init__.py +105 -0
- polos/agents/__init__.py +7 -0
- polos/agents/agent.py +746 -0
- polos/agents/conversation_history.py +121 -0
- polos/agents/stop_conditions.py +280 -0
- polos/agents/stream.py +635 -0
- polos/core/__init__.py +0 -0
- polos/core/context.py +143 -0
- polos/core/state.py +26 -0
- polos/core/step.py +1380 -0
- polos/core/workflow.py +1192 -0
- polos/features/__init__.py +0 -0
- polos/features/events.py +456 -0
- polos/features/schedules.py +110 -0
- polos/features/tracing.py +605 -0
- polos/features/wait.py +82 -0
- polos/llm/__init__.py +9 -0
- polos/llm/generate.py +152 -0
- polos/llm/providers/__init__.py +5 -0
- polos/llm/providers/anthropic.py +615 -0
- polos/llm/providers/azure.py +42 -0
- polos/llm/providers/base.py +196 -0
- polos/llm/providers/fireworks.py +41 -0
- polos/llm/providers/gemini.py +40 -0
- polos/llm/providers/groq.py +40 -0
- polos/llm/providers/openai.py +1021 -0
- polos/llm/providers/together.py +40 -0
- polos/llm/stream.py +183 -0
- polos/middleware/__init__.py +0 -0
- polos/middleware/guardrail.py +148 -0
- polos/middleware/guardrail_executor.py +253 -0
- polos/middleware/hook.py +164 -0
- polos/middleware/hook_executor.py +104 -0
- polos/runtime/__init__.py +0 -0
- polos/runtime/batch.py +87 -0
- polos/runtime/client.py +841 -0
- polos/runtime/queue.py +42 -0
- polos/runtime/worker.py +1365 -0
- polos/runtime/worker_server.py +249 -0
- polos/tools/__init__.py +0 -0
- polos/tools/tool.py +587 -0
- polos/types/__init__.py +23 -0
- polos/types/types.py +116 -0
- polos/utils/__init__.py +27 -0
- polos/utils/agent.py +27 -0
- polos/utils/client_context.py +41 -0
- polos/utils/config.py +12 -0
- polos/utils/output_schema.py +311 -0
- polos/utils/retry.py +47 -0
- polos/utils/serializer.py +167 -0
- polos/utils/tracing.py +27 -0
- polos/utils/worker_singleton.py +40 -0
- polos_sdk-0.1.0.dist-info/METADATA +650 -0
- polos_sdk-0.1.0.dist-info/RECORD +55 -0
- polos_sdk-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Utility functions for conversation history management."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
from urllib.parse import quote
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from ..utils.client_context import get_client_or_raise
|
|
9
|
+
from ..utils.worker_singleton import get_worker_client
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def add_conversation_history(
|
|
13
|
+
ctx: Any, # WorkflowContext
|
|
14
|
+
conversation_id: str,
|
|
15
|
+
agent_id: str,
|
|
16
|
+
role: str,
|
|
17
|
+
content: Any,
|
|
18
|
+
agent_run_id: str | None = None,
|
|
19
|
+
conversation_history_limit: int = 10,
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Add a message to conversation history with durable execution.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
ctx: WorkflowContext for durable execution
|
|
25
|
+
conversation_id: Conversation identifier
|
|
26
|
+
agent_id: Agent identifier
|
|
27
|
+
role: Message role ("user" or "assistant")
|
|
28
|
+
content: Message content (will be JSON-serialized)
|
|
29
|
+
agent_run_id: Optional agent run ID for traceability
|
|
30
|
+
conversation_history_limit: Maximum number of messages to keep (default: 10)
|
|
31
|
+
"""
|
|
32
|
+
# Content should be JSON-serializable (string, dict, list, etc.)
|
|
33
|
+
# Pass content as-is - httpx will serialize it to JSON
|
|
34
|
+
content_json = content
|
|
35
|
+
|
|
36
|
+
polos_client = get_client_or_raise()
|
|
37
|
+
api_url = polos_client.api_url
|
|
38
|
+
headers = polos_client._get_headers()
|
|
39
|
+
|
|
40
|
+
request_json = {
|
|
41
|
+
"agent_id": agent_id,
|
|
42
|
+
"role": role,
|
|
43
|
+
"content": content_json,
|
|
44
|
+
"conversation_history_limit": conversation_history_limit,
|
|
45
|
+
}
|
|
46
|
+
if agent_run_id:
|
|
47
|
+
request_json["agent_run_id"] = agent_run_id
|
|
48
|
+
|
|
49
|
+
# Try to reuse worker's HTTP client if available
|
|
50
|
+
worker_client = get_worker_client()
|
|
51
|
+
|
|
52
|
+
encoded_conversation_id = quote(conversation_id, safe="")
|
|
53
|
+
if worker_client is not None:
|
|
54
|
+
response = await worker_client.post(
|
|
55
|
+
f"{api_url}/internal/conversation/{encoded_conversation_id}/add",
|
|
56
|
+
json=request_json,
|
|
57
|
+
headers=headers,
|
|
58
|
+
)
|
|
59
|
+
response.raise_for_status()
|
|
60
|
+
else:
|
|
61
|
+
async with httpx.AsyncClient() as client:
|
|
62
|
+
response = await client.post(
|
|
63
|
+
f"{api_url}/internal/conversation/{encoded_conversation_id}/add",
|
|
64
|
+
json=request_json,
|
|
65
|
+
headers=headers,
|
|
66
|
+
)
|
|
67
|
+
response.raise_for_status()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def get_conversation_history(
|
|
71
|
+
conversation_id: str,
|
|
72
|
+
agent_id: str,
|
|
73
|
+
deployment_id: str | None = None,
|
|
74
|
+
limit: int | None = None,
|
|
75
|
+
) -> list[dict[str, Any]]:
|
|
76
|
+
"""Get conversation history for a conversation.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
conversation_id: Conversation identifier
|
|
80
|
+
agent_id: Agent identifier (required)
|
|
81
|
+
deployment_id: Optional deployment identifier
|
|
82
|
+
limit: Optional limit on number of messages to return
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
List of conversation messages (oldest first)
|
|
86
|
+
"""
|
|
87
|
+
polos_client = get_client_or_raise()
|
|
88
|
+
api_url = polos_client.api_url
|
|
89
|
+
headers = polos_client._get_headers()
|
|
90
|
+
|
|
91
|
+
params = {
|
|
92
|
+
"agent_id": agent_id,
|
|
93
|
+
}
|
|
94
|
+
if deployment_id is not None:
|
|
95
|
+
params["deployment_id"] = deployment_id
|
|
96
|
+
if limit is not None:
|
|
97
|
+
params["limit"] = limit
|
|
98
|
+
|
|
99
|
+
# Try to reuse worker's HTTP client if available
|
|
100
|
+
worker_client = get_worker_client()
|
|
101
|
+
|
|
102
|
+
encoded_conversation_id = quote(conversation_id, safe="")
|
|
103
|
+
if worker_client is not None:
|
|
104
|
+
response = await worker_client.get(
|
|
105
|
+
f"{api_url}/api/v1/conversation/{encoded_conversation_id}/get",
|
|
106
|
+
params=params,
|
|
107
|
+
headers=headers,
|
|
108
|
+
)
|
|
109
|
+
response.raise_for_status()
|
|
110
|
+
result = response.json()
|
|
111
|
+
return result.get("messages", [])
|
|
112
|
+
else:
|
|
113
|
+
async with httpx.AsyncClient() as client:
|
|
114
|
+
response = await client.get(
|
|
115
|
+
f"{api_url}/api/v1/conversation/{encoded_conversation_id}/get",
|
|
116
|
+
params=params,
|
|
117
|
+
headers=headers,
|
|
118
|
+
)
|
|
119
|
+
response.raise_for_status()
|
|
120
|
+
result = response.json()
|
|
121
|
+
return result.get("messages", [])
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Stop conditions for agents.
|
|
2
|
+
|
|
3
|
+
Stop conditions allow you to define when an agent should stop executing.
|
|
4
|
+
Stop conditions execute durably within workflow context using step.run().
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from ..types.types import Step
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class StopConditionContext(BaseModel):
|
|
19
|
+
"""Context available to stop conditions.
|
|
20
|
+
|
|
21
|
+
This context is passed to stop conditions and contains information about
|
|
22
|
+
the current execution state.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
# Steps executed so far
|
|
26
|
+
steps: list[Step] = []
|
|
27
|
+
|
|
28
|
+
# Optional agent context
|
|
29
|
+
agent_id: str | None = None
|
|
30
|
+
agent_run_id: str | None = None
|
|
31
|
+
|
|
32
|
+
def to_dict(self) -> dict[str, Any]:
|
|
33
|
+
"""Convert stop condition context to dictionary for serialization."""
|
|
34
|
+
return self.model_dump(mode="json")
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_dict(cls, data: Any) -> "StopConditionContext":
|
|
38
|
+
"""Create StopConditionContext from dictionary."""
|
|
39
|
+
if isinstance(data, StopConditionContext):
|
|
40
|
+
return data
|
|
41
|
+
if isinstance(data, dict):
|
|
42
|
+
return cls.model_validate(data)
|
|
43
|
+
raise TypeError(f"Cannot create StopConditionContext from {type(data)}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def stop_condition(fn: Callable) -> Callable:
|
|
47
|
+
"""
|
|
48
|
+
Decorator for stop condition functions.
|
|
49
|
+
|
|
50
|
+
Stop conditions take `ctx: StopConditionContext` as the first parameter
|
|
51
|
+
and optionally a second parameter (config class instance) for configuration.
|
|
52
|
+
They return a boolean (True to stop, False to continue).
|
|
53
|
+
|
|
54
|
+
When called with config params, they return a configured callable that will
|
|
55
|
+
be called later by the executor with StopConditionContext.
|
|
56
|
+
|
|
57
|
+
Usage:
|
|
58
|
+
# Simple stop condition (no config)
|
|
59
|
+
@stop_condition
|
|
60
|
+
async def always_stop(ctx: StopConditionContext) -> bool:
|
|
61
|
+
return True
|
|
62
|
+
|
|
63
|
+
# Stop condition with config
|
|
64
|
+
class MaxTokensConfig(BaseModel):
|
|
65
|
+
limit: int
|
|
66
|
+
|
|
67
|
+
@stop_condition
|
|
68
|
+
async def max_tokens(ctx: StopConditionContext, config: MaxTokensConfig) -> bool:
|
|
69
|
+
total = sum(step.usage.total_tokens if step.usage else 0 for step in ctx.steps)
|
|
70
|
+
return total >= config.limit
|
|
71
|
+
|
|
72
|
+
# Configure with limit
|
|
73
|
+
agent = Agent(..., stop_conditions=[max_tokens(MaxTokensConfig(limit=1000))])
|
|
74
|
+
"""
|
|
75
|
+
import functools
|
|
76
|
+
import inspect
|
|
77
|
+
|
|
78
|
+
sig = inspect.signature(fn)
|
|
79
|
+
params = list(sig.parameters.values())
|
|
80
|
+
|
|
81
|
+
# Validate signature: first parameter must be StopConditionContext
|
|
82
|
+
if not params or params[0].annotation != StopConditionContext:
|
|
83
|
+
raise TypeError(
|
|
84
|
+
f"Invalid stop_condition function '{fn.__name__}': "
|
|
85
|
+
f"first parameter must be typed as 'StopConditionContext'. "
|
|
86
|
+
f"Got {params[0].annotation if params else 'no parameters'}."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Check if there's a second parameter (for config) - it's optional
|
|
90
|
+
config_class = None
|
|
91
|
+
has_config = len(params) >= 2 and params[1].annotation != inspect.Signature.empty
|
|
92
|
+
if has_config:
|
|
93
|
+
config_class = params[1].annotation
|
|
94
|
+
|
|
95
|
+
# Validate that it's a Pydantic BaseModel
|
|
96
|
+
if not issubclass(config_class, BaseModel):
|
|
97
|
+
raise TypeError(
|
|
98
|
+
f"Invalid stop_condition function '{fn.__name__}': "
|
|
99
|
+
f"second parameter must be a Pydantic BaseModel class, got {config_class}."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Check return type annotation (should be bool)
|
|
103
|
+
return_annotation = sig.return_annotation
|
|
104
|
+
if return_annotation != inspect.Signature.empty and return_annotation not in (bool, "bool"):
|
|
105
|
+
import asyncio
|
|
106
|
+
from typing import get_args
|
|
107
|
+
|
|
108
|
+
# Check for Coroutine[Any, Any, bool]
|
|
109
|
+
is_async_bool = False
|
|
110
|
+
if hasattr(return_annotation, "__origin__"):
|
|
111
|
+
origin = return_annotation.__origin__
|
|
112
|
+
if origin is asyncio.Coroutine:
|
|
113
|
+
args = get_args(return_annotation)
|
|
114
|
+
if len(args) >= 3 and args[2] is bool:
|
|
115
|
+
is_async_bool = True
|
|
116
|
+
if not is_async_bool:
|
|
117
|
+
logger.warning(
|
|
118
|
+
"stop_condition '%s' should return bool or Coroutine[..., bool], got %s",
|
|
119
|
+
fn.__name__,
|
|
120
|
+
return_annotation,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Check if function is async
|
|
124
|
+
import asyncio
|
|
125
|
+
|
|
126
|
+
is_async = asyncio.iscoroutinefunction(fn)
|
|
127
|
+
|
|
128
|
+
@functools.wraps(fn)
|
|
129
|
+
def wrapper(*args, **kwargs):
|
|
130
|
+
"""
|
|
131
|
+
Wrapper that returns a configured callable when called with config params.
|
|
132
|
+
The configured callable will be called later by the executor with StopConditionContext.
|
|
133
|
+
"""
|
|
134
|
+
if has_config:
|
|
135
|
+
# Function requires config, so when called with args/kwargs, return configured callable
|
|
136
|
+
# Create config instance from captured args/kwargs
|
|
137
|
+
if args and len(args) == 1 and isinstance(args[0], config_class):
|
|
138
|
+
# Already an instance of config_class
|
|
139
|
+
config = args[0]
|
|
140
|
+
elif args and len(args) == 1 and isinstance(args[0], dict):
|
|
141
|
+
# Dict provided, use Pydantic's model_validate
|
|
142
|
+
config = config_class.model_validate(args[0])
|
|
143
|
+
elif kwargs:
|
|
144
|
+
# Keyword arguments provided, Pydantic constructor handles this
|
|
145
|
+
config = config_class(**kwargs)
|
|
146
|
+
elif args:
|
|
147
|
+
# Positional arguments provided, Pydantic constructor handles this
|
|
148
|
+
config = config_class(*args)
|
|
149
|
+
else:
|
|
150
|
+
raise ValueError(
|
|
151
|
+
f"stop_condition '{fn.__name__}' requires config but none provided"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Create configured callable that captures config
|
|
155
|
+
# Preserve async/sync behavior of original function
|
|
156
|
+
if is_async:
|
|
157
|
+
|
|
158
|
+
async def configured_callable(ctx: StopConditionContext) -> bool:
|
|
159
|
+
"""Async configured callable that executes the original function
|
|
160
|
+
with captured config."""
|
|
161
|
+
return await fn(ctx, config)
|
|
162
|
+
else:
|
|
163
|
+
|
|
164
|
+
def configured_callable(ctx: StopConditionContext) -> bool:
|
|
165
|
+
"""Sync configured callable that executes the original function
|
|
166
|
+
with captured config."""
|
|
167
|
+
return fn(ctx, config)
|
|
168
|
+
|
|
169
|
+
# Add metadata for identification
|
|
170
|
+
configured_callable.__stop_condition_fn__ = fn
|
|
171
|
+
configured_callable.__stop_condition_name__ = fn.__name__
|
|
172
|
+
configured_callable.__stop_condition_config__ = config
|
|
173
|
+
|
|
174
|
+
return configured_callable
|
|
175
|
+
else:
|
|
176
|
+
# No config needed - the function itself can be called with ctx
|
|
177
|
+
# step.run() will handle it correctly
|
|
178
|
+
return fn
|
|
179
|
+
|
|
180
|
+
# Add metadata for identification
|
|
181
|
+
wrapper.__stop_condition_fn__ = fn
|
|
182
|
+
wrapper.__stop_condition_name__ = fn.__name__
|
|
183
|
+
wrapper.__stop_condition_has_config__ = has_config
|
|
184
|
+
wrapper.__stop_condition_config_class__ = config_class
|
|
185
|
+
|
|
186
|
+
return wrapper
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# Built-in stop condition functions
|
|
190
|
+
class MaxTokensConfig(BaseModel):
|
|
191
|
+
"""Configuration for max_tokens stop condition."""
|
|
192
|
+
|
|
193
|
+
limit: int
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@stop_condition
|
|
197
|
+
async def max_tokens(ctx: StopConditionContext, config: MaxTokensConfig) -> bool:
|
|
198
|
+
"""
|
|
199
|
+
Stop when total tokens exceed limit.
|
|
200
|
+
|
|
201
|
+
Usage:
|
|
202
|
+
agent = Agent(..., stop_conditions=[max_tokens(MaxTokensConfig(limit=1000))])
|
|
203
|
+
"""
|
|
204
|
+
total = 0
|
|
205
|
+
for step in ctx.steps:
|
|
206
|
+
if step.usage:
|
|
207
|
+
total += step.usage.total_tokens
|
|
208
|
+
return total >= config.limit
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class MaxStepsConfig(BaseModel):
|
|
212
|
+
"""Configuration for max_steps stop condition."""
|
|
213
|
+
|
|
214
|
+
count: int = 5
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@stop_condition
|
|
218
|
+
def max_steps(ctx: StopConditionContext, config: MaxStepsConfig) -> bool:
|
|
219
|
+
"""
|
|
220
|
+
Stop when number of steps reaches count.
|
|
221
|
+
|
|
222
|
+
Usage:
|
|
223
|
+
agent = Agent(..., stop_conditions=[max_steps()]) # Uses default count=5
|
|
224
|
+
agent = Agent(..., stop_conditions=[max_steps(MaxStepsConfig(count=10))]) # Custom count
|
|
225
|
+
"""
|
|
226
|
+
return len(ctx.steps) >= config.count
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class ExecutedToolConfig(BaseModel):
|
|
230
|
+
"""Configuration for executed_tool stop condition."""
|
|
231
|
+
|
|
232
|
+
tool_names: list[str]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@stop_condition
|
|
236
|
+
def executed_tool(ctx: StopConditionContext, config: ExecutedToolConfig) -> bool:
|
|
237
|
+
"""
|
|
238
|
+
Stop when all specified tools have been executed.
|
|
239
|
+
|
|
240
|
+
Usage:
|
|
241
|
+
agent = Agent(..., stop_conditions=[
|
|
242
|
+
executed_tool(ExecutedToolConfig(tool_names=["get_weather", "search"]))
|
|
243
|
+
])
|
|
244
|
+
"""
|
|
245
|
+
required = set(config.tool_names)
|
|
246
|
+
if not required:
|
|
247
|
+
return False
|
|
248
|
+
|
|
249
|
+
executed = set()
|
|
250
|
+
for step in ctx.steps:
|
|
251
|
+
for tool_call in step.tool_calls:
|
|
252
|
+
executed.add(tool_call.function.name)
|
|
253
|
+
return required.issubset(executed)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class HasTextConfig(BaseModel):
|
|
257
|
+
"""Configuration for has_text stop condition."""
|
|
258
|
+
|
|
259
|
+
texts: list[str]
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@stop_condition
|
|
263
|
+
def has_text(ctx: StopConditionContext, config: HasTextConfig) -> bool:
|
|
264
|
+
"""
|
|
265
|
+
Stop when all specified texts are found in response.
|
|
266
|
+
|
|
267
|
+
Usage:
|
|
268
|
+
agent = Agent(..., stop_conditions=[has_text(HasTextConfig(texts=["done", "complete"]))])
|
|
269
|
+
"""
|
|
270
|
+
if not config.texts:
|
|
271
|
+
return False
|
|
272
|
+
|
|
273
|
+
# Concatenate all content strings from steps
|
|
274
|
+
combined = []
|
|
275
|
+
for step in ctx.steps:
|
|
276
|
+
if step.content:
|
|
277
|
+
combined.append(step.content)
|
|
278
|
+
full_text = " ".join(combined)
|
|
279
|
+
|
|
280
|
+
return all(t in full_text for t in config.texts)
|