pi-ai-python 1.3.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.
- pi_agent/__init__.py +127 -0
- pi_agent/agent.py +617 -0
- pi_agent/agent_loop.py +820 -0
- pi_agent/proxy.py +269 -0
- pi_agent/py.typed +0 -0
- pi_agent/stream_fn.py +27 -0
- pi_agent/tools.py +327 -0
- pi_agent/types.py +394 -0
- pi_ai/__init__.py +263 -0
- pi_ai/abort.py +315 -0
- pi_ai/api/__init__.py +1 -0
- pi_ai/api/anthropic_messages.py +1782 -0
- pi_ai/api/azure_openai_config.py +138 -0
- pi_ai/api/azure_openai_responses.py +245 -0
- pi_ai/api/bedrock_converse_stream.py +1598 -0
- pi_ai/api/classifier_shared.py +211 -0
- pi_ai/api/cloudflare.py +36 -0
- pi_ai/api/cloudflare_workers_ai_system_one.py +80 -0
- pi_ai/api/constrained_sampling.py +279 -0
- pi_ai/api/github_copilot_headers.py +30 -0
- pi_ai/api/google_generative_ai.py +138 -0
- pi_ai/api/google_shared.py +927 -0
- pi_ai/api/google_vertex.py +234 -0
- pi_ai/api/lazy.py +156 -0
- pi_ai/api/llama_cpp_classify.py +449 -0
- pi_ai/api/mistral_conversations.py +756 -0
- pi_ai/api/openai_codex_responses.py +1545 -0
- pi_ai/api/openai_completions.py +1594 -0
- pi_ai/api/openai_decisions.py +210 -0
- pi_ai/api/openai_prompt_cache.py +19 -0
- pi_ai/api/openai_responses.py +418 -0
- pi_ai/api/openai_responses_shared.py +840 -0
- pi_ai/api/openai_sdk.py +204 -0
- pi_ai/api/openrouter_images.py +153 -0
- pi_ai/api/pi_messages.py +449 -0
- pi_ai/api/registry.py +103 -0
- pi_ai/api/simple_options.py +119 -0
- pi_ai/api/system_one_shared.py +148 -0
- pi_ai/api/transform_messages.py +211 -0
- pi_ai/api/typesafe_system_one.py +43 -0
- pi_ai/auth/__init__.py +1 -0
- pi_ai/auth/context.py +47 -0
- pi_ai/auth/credential_store.py +208 -0
- pi_ai/auth/helpers.py +123 -0
- pi_ai/auth/oauth/__init__.py +5 -0
- pi_ai/auth/oauth/_common.py +454 -0
- pi_ai/auth/oauth/anthropic.py +283 -0
- pi_ai/auth/oauth/callback_server.py +339 -0
- pi_ai/auth/oauth/device_code.py +127 -0
- pi_ai/auth/oauth/github_copilot.py +508 -0
- pi_ai/auth/oauth/kimi_coding.py +283 -0
- pi_ai/auth/oauth/load.py +64 -0
- pi_ai/auth/oauth/meta.py +238 -0
- pi_ai/auth/oauth/openai_chatgpt.py +321 -0
- pi_ai/auth/oauth/openai_codex.py +391 -0
- pi_ai/auth/oauth/openrouter.py +173 -0
- pi_ai/auth/oauth/pkce.py +23 -0
- pi_ai/auth/oauth/radius.py +311 -0
- pi_ai/auth/oauth/xai.py +220 -0
- pi_ai/auth/resolve.py +190 -0
- pi_ai/auth/types.py +292 -0
- pi_ai/cli.py +265 -0
- pi_ai/compat.py +651 -0
- pi_ai/env_api_keys.py +137 -0
- pi_ai/event_stream.py +329 -0
- pi_ai/models.py +1191 -0
- pi_ai/models_store.py +121 -0
- pi_ai/oauth.py +90 -0
- pi_ai/providers/__init__.py +1 -0
- pi_ai/providers/all.py +154 -0
- pi_ai/providers/amazon_bedrock.py +116 -0
- pi_ai/providers/ant_ling.py +23 -0
- pi_ai/providers/anthropic.py +105 -0
- pi_ai/providers/azure.py +103 -0
- pi_ai/providers/baseten.py +23 -0
- pi_ai/providers/catalog.py +167 -0
- pi_ai/providers/cerebras.py +23 -0
- pi_ai/providers/cloudflare_ai_gateway.py +30 -0
- pi_ai/providers/cloudflare_auth.py +112 -0
- pi_ai/providers/cloudflare_stream.py +84 -0
- pi_ai/providers/cloudflare_workers_ai.py +26 -0
- pi_ai/providers/data/amazon-bedrock.json +1 -0
- pi_ai/providers/data/ant-ling.json +1 -0
- pi_ai/providers/data/anthropic.json +1 -0
- pi_ai/providers/data/azure.json +1 -0
- pi_ai/providers/data/baseten.json +1 -0
- pi_ai/providers/data/cerebras.json +1 -0
- pi_ai/providers/data/cloudflare-ai-gateway.json +1 -0
- pi_ai/providers/data/cloudflare-workers-ai.json +1 -0
- pi_ai/providers/data/deepseek.json +1 -0
- pi_ai/providers/data/fireworks.json +1 -0
- pi_ai/providers/data/github-copilot.json +1 -0
- pi_ai/providers/data/google-vertex.json +1 -0
- pi_ai/providers/data/google.json +1 -0
- pi_ai/providers/data/groq.json +1 -0
- pi_ai/providers/data/huggingface.json +1 -0
- pi_ai/providers/data/kimi-coding.json +1 -0
- pi_ai/providers/data/manifest.json +1 -0
- pi_ai/providers/data/meta.json +1 -0
- pi_ai/providers/data/minimax-cn.json +1 -0
- pi_ai/providers/data/minimax.json +1 -0
- pi_ai/providers/data/mistral.json +1 -0
- pi_ai/providers/data/moonshotai-cn.json +1 -0
- pi_ai/providers/data/moonshotai.json +1 -0
- pi_ai/providers/data/nvidia.json +1 -0
- pi_ai/providers/data/openai-codex.json +1 -0
- pi_ai/providers/data/openai.json +1 -0
- pi_ai/providers/data/opencode-go.json +1 -0
- pi_ai/providers/data/opencode.json +1 -0
- pi_ai/providers/data/openrouter.json +1 -0
- pi_ai/providers/data/qwen-token-plan-cn.json +1 -0
- pi_ai/providers/data/qwen-token-plan-individual.json +1 -0
- pi_ai/providers/data/qwen-token-plan.json +1 -0
- pi_ai/providers/data/radius.json +1 -0
- pi_ai/providers/data/together.json +1 -0
- pi_ai/providers/data/typesafe.json +1 -0
- pi_ai/providers/data/vercel-ai-gateway.json +1 -0
- pi_ai/providers/data/xai.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-ams.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-cn.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-sgp.json +1 -0
- pi_ai/providers/data/xiaomi.json +1 -0
- pi_ai/providers/data/zai-coding-cn.json +1 -0
- pi_ai/providers/data/zai.json +1 -0
- pi_ai/providers/deepseek.py +23 -0
- pi_ai/providers/faux.py +680 -0
- pi_ai/providers/fireworks.py +26 -0
- pi_ai/providers/github_copilot.py +46 -0
- pi_ai/providers/google.py +23 -0
- pi_ai/providers/google_vertex.py +131 -0
- pi_ai/providers/groq.py +23 -0
- pi_ai/providers/huggingface.py +23 -0
- pi_ai/providers/kimi_coding.py +32 -0
- pi_ai/providers/meta.py +32 -0
- pi_ai/providers/minimax.py +23 -0
- pi_ai/providers/minimax_cn.py +23 -0
- pi_ai/providers/mistral.py +23 -0
- pi_ai/providers/moonshotai.py +23 -0
- pi_ai/providers/moonshotai_cn.py +23 -0
- pi_ai/providers/nvidia.py +23 -0
- pi_ai/providers/openai.py +44 -0
- pi_ai/providers/openai_codex.py +26 -0
- pi_ai/providers/opencode.py +36 -0
- pi_ai/providers/opencode_go.py +33 -0
- pi_ai/providers/opencode_headers.py +60 -0
- pi_ai/providers/openrouter.py +145 -0
- pi_ai/providers/openrouter_api.py +122 -0
- pi_ai/providers/qwen_token_plan.py +23 -0
- pi_ai/providers/qwen_token_plan_cn.py +23 -0
- pi_ai/providers/qwen_token_plan_individual.py +23 -0
- pi_ai/providers/radius.py +146 -0
- pi_ai/providers/radius_config.py +136 -0
- pi_ai/providers/together.py +23 -0
- pi_ai/providers/typesafe.py +22 -0
- pi_ai/providers/vercel_ai_gateway.py +25 -0
- pi_ai/providers/xai.py +32 -0
- pi_ai/providers/xiaomi.py +23 -0
- pi_ai/providers/xiaomi_token_plan_ams.py +23 -0
- pi_ai/providers/xiaomi_token_plan_cn.py +23 -0
- pi_ai/providers/xiaomi_token_plan_sgp.py +23 -0
- pi_ai/providers/zai.py +23 -0
- pi_ai/providers/zai_coding_cn.py +23 -0
- pi_ai/py.typed +0 -0
- pi_ai/serialization.py +477 -0
- pi_ai/session_resources.py +33 -0
- pi_ai/types.py +1003 -0
- pi_ai/utils/__init__.py +1 -0
- pi_ai/utils/callbacks.py +15 -0
- pi_ai/utils/diagnostics.py +70 -0
- pi_ai/utils/error_body.py +89 -0
- pi_ai/utils/estimate.py +125 -0
- pi_ai/utils/fastjson.py +63 -0
- pi_ai/utils/http.py +409 -0
- pi_ai/utils/json_parse.py +424 -0
- pi_ai/utils/misc.py +193 -0
- pi_ai/utils/model_operations.py +73 -0
- pi_ai/utils/models_error.py +35 -0
- pi_ai/utils/oauth_page.py +121 -0
- pi_ai/utils/overflow.py +87 -0
- pi_ai/utils/retry.py +193 -0
- pi_ai/utils/schema.py +283 -0
- pi_ai/utils/text.py +65 -0
- pi_ai/utils/transcript.py +271 -0
- pi_ai/utils/validation.py +813 -0
- pi_ai_python-1.3.0.dist-info/METADATA +167 -0
- pi_ai_python-1.3.0.dist-info/RECORD +189 -0
- pi_ai_python-1.3.0.dist-info/WHEEL +4 -0
- pi_ai_python-1.3.0.dist-info/entry_points.txt +2 -0
- pi_ai_python-1.3.0.dist-info/licenses/LICENSE +22 -0
pi_agent/agent.py
ADDED
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
"""Stateful wrapper around the low-level agent loop."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
from pi_ai import (
|
|
11
|
+
AbortController,
|
|
12
|
+
AbortSignal,
|
|
13
|
+
AssistantMessage,
|
|
14
|
+
ImageContent,
|
|
15
|
+
Message,
|
|
16
|
+
Model,
|
|
17
|
+
TextContent,
|
|
18
|
+
UserMessage,
|
|
19
|
+
create_initial_system_message,
|
|
20
|
+
get_current_system_message,
|
|
21
|
+
get_current_system_prompt,
|
|
22
|
+
now_ms,
|
|
23
|
+
to_tool_declaration,
|
|
24
|
+
)
|
|
25
|
+
from pi_ai.utils.callbacks import maybe_await
|
|
26
|
+
|
|
27
|
+
from .agent_loop import run_agent_loop, run_agent_loop_continue
|
|
28
|
+
from .stream_fn import get_default_stream_fn
|
|
29
|
+
from .types import (
|
|
30
|
+
AfterToolCallContext,
|
|
31
|
+
AfterToolCallResult,
|
|
32
|
+
AgentContext,
|
|
33
|
+
AgentEndEvent,
|
|
34
|
+
AgentEvent,
|
|
35
|
+
AgentLoopConfig,
|
|
36
|
+
AgentLoopTurnUpdate,
|
|
37
|
+
AgentTool,
|
|
38
|
+
AgentTurnContext,
|
|
39
|
+
BeforeToolCallContext,
|
|
40
|
+
BeforeToolCallResult,
|
|
41
|
+
FinishTurn,
|
|
42
|
+
MessageEndEvent,
|
|
43
|
+
MessageStartEvent,
|
|
44
|
+
PrepareRequest,
|
|
45
|
+
QueueMode,
|
|
46
|
+
StreamFn,
|
|
47
|
+
ThinkingLevel,
|
|
48
|
+
ToolExecutionMode,
|
|
49
|
+
TurnEndEvent,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
__all__ = ["Agent", "AgentState", "AgentInitialState", "AgentListener", "default_convert_to_llm"]
|
|
53
|
+
|
|
54
|
+
_LLM_ROLES = frozenset({"system", "user", "assistant", "toolResult"})
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def default_convert_to_llm(messages: list[Any]) -> list[Message]:
|
|
58
|
+
"""Keep only messages the LLM understands (``system``, ``user``, ``assistant``, ``toolResult``)."""
|
|
59
|
+
return [message for message in messages if getattr(message, "role", None) in _LLM_ROLES]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_DEFAULT_MODEL = Model(
|
|
63
|
+
id="unknown",
|
|
64
|
+
name="unknown",
|
|
65
|
+
api="unknown",
|
|
66
|
+
provider="unknown",
|
|
67
|
+
base_url="",
|
|
68
|
+
reasoning=False,
|
|
69
|
+
input=[],
|
|
70
|
+
context_window=0,
|
|
71
|
+
max_tokens=0,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
AgentListener = Callable[[AgentEvent, AbortSignal], "Awaitable[None] | None"]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(slots=True)
|
|
78
|
+
class AgentInitialState:
|
|
79
|
+
"""Initial state for :class:`Agent`.
|
|
80
|
+
|
|
81
|
+
``system_prompt`` and ``tools`` become the leading system message unless
|
|
82
|
+
``messages`` already starts with one.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
system_prompt: str | None = None
|
|
86
|
+
model: Model | None = None
|
|
87
|
+
thinking_level: ThinkingLevel = "off"
|
|
88
|
+
tools: list[AgentTool] = field(default_factory=list)
|
|
89
|
+
messages: list[Any] = field(default_factory=list)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class AgentState:
|
|
93
|
+
"""Public agent state.
|
|
94
|
+
|
|
95
|
+
Assigning ``tools`` or ``messages`` copies the top-level list. ``system_prompt`` is
|
|
96
|
+
read-only and replays the transcript's system messages; to change the prompt,
|
|
97
|
+
append a system message with ``content`` or ``sections``.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
__slots__ = (
|
|
101
|
+
"model",
|
|
102
|
+
"thinking_level",
|
|
103
|
+
"_tools",
|
|
104
|
+
"_messages",
|
|
105
|
+
"is_streaming",
|
|
106
|
+
"streaming_message",
|
|
107
|
+
"pending_tool_calls",
|
|
108
|
+
"error_message",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def __init__(self, initial: AgentInitialState | Mapping[str, Any] | None = None) -> None:
|
|
112
|
+
if isinstance(initial, Mapping):
|
|
113
|
+
initial = AgentInitialState(**initial)
|
|
114
|
+
initial = initial or AgentInitialState()
|
|
115
|
+
tools = list(initial.tools or [])
|
|
116
|
+
messages = _coerce_messages(list(initial.messages or []))
|
|
117
|
+
initial_message = create_initial_system_message(
|
|
118
|
+
initial.system_prompt, [to_tool_declaration(tool) for tool in tools]
|
|
119
|
+
)
|
|
120
|
+
if initial_message is not None and (not messages or getattr(messages[0], "role", None) != "system"):
|
|
121
|
+
messages.insert(0, initial_message)
|
|
122
|
+
self.model: Model = initial.model or _DEFAULT_MODEL
|
|
123
|
+
self.thinking_level: ThinkingLevel = initial.thinking_level or "off"
|
|
124
|
+
self._tools: list[AgentTool] = tools
|
|
125
|
+
self._messages: list[Any] = messages
|
|
126
|
+
#: True while a prompt or continuation runs, until awaited ``agent_end`` listeners settle.
|
|
127
|
+
self.is_streaming = False
|
|
128
|
+
#: Partial assistant message for the current streamed response.
|
|
129
|
+
self.streaming_message: Any = None
|
|
130
|
+
#: Tool call ids currently executing.
|
|
131
|
+
self.pending_tool_calls: frozenset[str] = frozenset()
|
|
132
|
+
#: Error message from the most recent failed or aborted assistant turn.
|
|
133
|
+
self.error_message: str | None = None
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def system_prompt(self) -> str:
|
|
137
|
+
return get_current_system_prompt(self._messages)
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def tools(self) -> list[AgentTool]:
|
|
141
|
+
return self._tools
|
|
142
|
+
|
|
143
|
+
@tools.setter
|
|
144
|
+
def tools(self, tools: Sequence[AgentTool]) -> None:
|
|
145
|
+
self._tools = list(tools)
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def messages(self) -> list[Any]:
|
|
149
|
+
return self._messages
|
|
150
|
+
|
|
151
|
+
@messages.setter
|
|
152
|
+
def messages(self, messages: Sequence[Any]) -> None:
|
|
153
|
+
self._messages = _coerce_messages(list(messages))
|
|
154
|
+
|
|
155
|
+
def __repr__(self) -> str:
|
|
156
|
+
return (
|
|
157
|
+
f"AgentState(model={self.model.provider}/{self.model.id}, messages={len(self._messages)}, "
|
|
158
|
+
f"tools={[tool.name for tool in self._tools]}, is_streaming={self.is_streaming})"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _coerce_messages(messages: list[Any]) -> list[Any]:
|
|
163
|
+
if any(isinstance(message, Mapping) for message in messages):
|
|
164
|
+
from pi_ai.serialization import message_from_dict
|
|
165
|
+
|
|
166
|
+
return [message_from_dict(message) if isinstance(message, Mapping) else message for message in messages]
|
|
167
|
+
return messages
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class _PendingMessageQueue:
|
|
171
|
+
__slots__ = ("mode", "_messages")
|
|
172
|
+
|
|
173
|
+
def __init__(self, mode: QueueMode) -> None:
|
|
174
|
+
self.mode: QueueMode = mode
|
|
175
|
+
self._messages: list[Any] = []
|
|
176
|
+
|
|
177
|
+
def enqueue(self, message: Any) -> None:
|
|
178
|
+
self._messages.append(message)
|
|
179
|
+
|
|
180
|
+
def has_items(self) -> bool:
|
|
181
|
+
return bool(self._messages)
|
|
182
|
+
|
|
183
|
+
def peek(self) -> list[Any]:
|
|
184
|
+
if self.mode == "all":
|
|
185
|
+
return list(self._messages)
|
|
186
|
+
return self._messages[:1]
|
|
187
|
+
|
|
188
|
+
def drain(self) -> list[Any]:
|
|
189
|
+
drained = self.peek()
|
|
190
|
+
self._messages = self._messages[len(drained) :]
|
|
191
|
+
return drained
|
|
192
|
+
|
|
193
|
+
def clear(self) -> None:
|
|
194
|
+
self._messages = []
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@dataclass(slots=True)
|
|
198
|
+
class _ActiveRun:
|
|
199
|
+
done: asyncio.Event
|
|
200
|
+
abort_controller: AbortController
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class Agent:
|
|
204
|
+
"""Stateful agent: owns the transcript, emits lifecycle events, executes tools,
|
|
205
|
+
and queues steering and follow-up messages.
|
|
206
|
+
|
|
207
|
+
::
|
|
208
|
+
|
|
209
|
+
models = builtin_models()
|
|
210
|
+
agent = Agent(
|
|
211
|
+
initial_state=AgentInitialState(system_prompt="You are helpful.", model=models.get_model("anthropic", "claude-sonnet-4-5")),
|
|
212
|
+
stream_fn=models.stream_simple,
|
|
213
|
+
)
|
|
214
|
+
agent.subscribe(lambda event, signal: print(event.type))
|
|
215
|
+
await agent.prompt("Hello!")
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
def __init__(
|
|
219
|
+
self,
|
|
220
|
+
*,
|
|
221
|
+
initial_state: AgentInitialState | Mapping[str, Any] | None = None,
|
|
222
|
+
stream_fn: StreamFn | Any = None,
|
|
223
|
+
convert_to_llm: Callable[[list[Any]], list[Message] | Awaitable[list[Message]]] | None = None,
|
|
224
|
+
transform_context: Callable[[list[Any], AbortSignal | None], Awaitable[list[Any]]] | None = None,
|
|
225
|
+
get_api_key: Callable[[str], str | Awaitable[str | None] | None] | None = None,
|
|
226
|
+
on_payload: Callable[[Any, Model], Any] | None = None,
|
|
227
|
+
on_response: Callable[[Any, Model], Any] | None = None,
|
|
228
|
+
on_provider_stream_event: Callable[[Any, Model], Any] | None = None,
|
|
229
|
+
before_tool_call: Callable[[BeforeToolCallContext, AbortSignal | None], Awaitable[BeforeToolCallResult | None]]
|
|
230
|
+
| None = None,
|
|
231
|
+
after_tool_call: Callable[[AfterToolCallContext, AbortSignal | None], Awaitable[AfterToolCallResult | None]]
|
|
232
|
+
| None = None,
|
|
233
|
+
finish_turn: FinishTurn | None = None,
|
|
234
|
+
prepare_request: PrepareRequest | None = None,
|
|
235
|
+
prepare_next_turn: Callable[[AbortSignal | None], Any] | None = None,
|
|
236
|
+
prepare_next_turn_with_context: Callable[[AgentTurnContext, AbortSignal | None], Any] | None = None,
|
|
237
|
+
steering_mode: QueueMode = "one-at-a-time",
|
|
238
|
+
follow_up_mode: QueueMode = "one-at-a-time",
|
|
239
|
+
session_id: str | None = None,
|
|
240
|
+
thinking_budgets: Mapping[str, int] | None = None,
|
|
241
|
+
transport: str = "auto",
|
|
242
|
+
max_retry_delay_ms: float | None = None,
|
|
243
|
+
tool_execution: ToolExecutionMode = "parallel",
|
|
244
|
+
stream_options: Mapping[str, Any] | None = None,
|
|
245
|
+
) -> None:
|
|
246
|
+
self._state = AgentState(initial_state)
|
|
247
|
+
self._listeners: list[AgentListener] = []
|
|
248
|
+
self._steering_queue = _PendingMessageQueue(steering_mode)
|
|
249
|
+
self._follow_up_queue = _PendingMessageQueue(follow_up_mode)
|
|
250
|
+
self._active_run: _ActiveRun | None = None
|
|
251
|
+
if stream_fn is not None and not callable(stream_fn) and hasattr(stream_fn, "stream_simple"):
|
|
252
|
+
stream_fn = stream_fn.stream_simple # accept a Models collection directly
|
|
253
|
+
self.stream_function: StreamFn = cast(StreamFn, stream_fn) if stream_fn is not None else get_default_stream_fn()
|
|
254
|
+
self.convert_to_llm = convert_to_llm or default_convert_to_llm
|
|
255
|
+
self.transform_context = transform_context
|
|
256
|
+
self.get_api_key = get_api_key
|
|
257
|
+
self.on_payload = on_payload
|
|
258
|
+
self.on_response = on_response
|
|
259
|
+
self.on_provider_stream_event = on_provider_stream_event
|
|
260
|
+
self.before_tool_call = before_tool_call
|
|
261
|
+
self.after_tool_call = after_tool_call
|
|
262
|
+
self.finish_turn = finish_turn
|
|
263
|
+
self.prepare_request = prepare_request
|
|
264
|
+
self.prepare_next_turn = prepare_next_turn
|
|
265
|
+
self.prepare_next_turn_with_context = prepare_next_turn_with_context
|
|
266
|
+
#: Session identifier forwarded to providers for cache-aware backends.
|
|
267
|
+
self.session_id = session_id
|
|
268
|
+
#: Optional per-level thinking token budgets.
|
|
269
|
+
self.thinking_budgets = dict(thinking_budgets) if thinking_budgets else None
|
|
270
|
+
#: Preferred transport forwarded to the stream function.
|
|
271
|
+
self.transport = transport
|
|
272
|
+
#: Optional cap for provider-requested retry delays.
|
|
273
|
+
self.max_retry_delay_ms = max_retry_delay_ms
|
|
274
|
+
#: Tool execution strategy for assistant messages with multiple tool calls.
|
|
275
|
+
self.tool_execution: ToolExecutionMode = tool_execution
|
|
276
|
+
#: Extra stream options (temperature, max_tokens, cache_retention, headers...).
|
|
277
|
+
self.stream_options: dict[str, Any] = dict(stream_options or {})
|
|
278
|
+
|
|
279
|
+
# -- events and state -------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
def subscribe(self, listener: AgentListener) -> Callable[[], None]:
|
|
282
|
+
"""Subscribe to lifecycle events. Returns an unsubscribe function.
|
|
283
|
+
|
|
284
|
+
Listeners (sync or async) are awaited in subscription order and count toward
|
|
285
|
+
the current run's settlement. They receive the run's abort signal.
|
|
286
|
+
"""
|
|
287
|
+
self._listeners.append(listener)
|
|
288
|
+
|
|
289
|
+
def unsubscribe() -> None:
|
|
290
|
+
if listener in self._listeners:
|
|
291
|
+
self._listeners.remove(listener)
|
|
292
|
+
|
|
293
|
+
return unsubscribe
|
|
294
|
+
|
|
295
|
+
@property
|
|
296
|
+
def state(self) -> AgentState:
|
|
297
|
+
return self._state
|
|
298
|
+
|
|
299
|
+
@property
|
|
300
|
+
def steering_mode(self) -> QueueMode:
|
|
301
|
+
return self._steering_queue.mode
|
|
302
|
+
|
|
303
|
+
@steering_mode.setter
|
|
304
|
+
def steering_mode(self, mode: QueueMode) -> None:
|
|
305
|
+
self._steering_queue.mode = mode
|
|
306
|
+
|
|
307
|
+
@property
|
|
308
|
+
def follow_up_mode(self) -> QueueMode:
|
|
309
|
+
return self._follow_up_queue.mode
|
|
310
|
+
|
|
311
|
+
@follow_up_mode.setter
|
|
312
|
+
def follow_up_mode(self, mode: QueueMode) -> None:
|
|
313
|
+
self._follow_up_queue.mode = mode
|
|
314
|
+
|
|
315
|
+
def steer(self, message: Any) -> None:
|
|
316
|
+
"""Queue a message to inject after the current assistant turn finishes."""
|
|
317
|
+
self._steering_queue.enqueue(_coerce_messages([message])[0])
|
|
318
|
+
|
|
319
|
+
def follow_up(self, message: Any) -> None:
|
|
320
|
+
"""Queue a message to run only after the agent would otherwise stop."""
|
|
321
|
+
self._follow_up_queue.enqueue(_coerce_messages([message])[0])
|
|
322
|
+
|
|
323
|
+
def clear_steering_queue(self) -> None:
|
|
324
|
+
self._steering_queue.clear()
|
|
325
|
+
|
|
326
|
+
def clear_follow_up_queue(self) -> None:
|
|
327
|
+
self._follow_up_queue.clear()
|
|
328
|
+
|
|
329
|
+
def clear_all_queues(self) -> None:
|
|
330
|
+
self.clear_steering_queue()
|
|
331
|
+
self.clear_follow_up_queue()
|
|
332
|
+
|
|
333
|
+
def has_queued_messages(self) -> bool:
|
|
334
|
+
return self._steering_queue.has_items() or self._follow_up_queue.has_items()
|
|
335
|
+
|
|
336
|
+
def peek_queued_messages(self) -> list[Any]:
|
|
337
|
+
"""Preview the messages selected for the next turn without consuming them."""
|
|
338
|
+
steering = self._steering_queue.peek()
|
|
339
|
+
return steering if steering else self._follow_up_queue.peek()
|
|
340
|
+
|
|
341
|
+
@property
|
|
342
|
+
def signal(self) -> AbortSignal | None:
|
|
343
|
+
"""Abort signal of the current run, if any."""
|
|
344
|
+
return self._active_run.abort_controller.signal if self._active_run else None
|
|
345
|
+
|
|
346
|
+
@property
|
|
347
|
+
def is_running(self) -> bool:
|
|
348
|
+
return self._active_run is not None
|
|
349
|
+
|
|
350
|
+
def abort(self) -> None:
|
|
351
|
+
"""Abort the current run, if one is active."""
|
|
352
|
+
if self._active_run is not None:
|
|
353
|
+
self._active_run.abort_controller.abort()
|
|
354
|
+
|
|
355
|
+
async def wait_for_idle(self) -> None:
|
|
356
|
+
"""Wait until the current run and all awaited listeners (incl. ``agent_end``) finished."""
|
|
357
|
+
run = self._active_run
|
|
358
|
+
if run is not None:
|
|
359
|
+
await run.done.wait()
|
|
360
|
+
|
|
361
|
+
def reset(self) -> None:
|
|
362
|
+
"""Clear conversation state and queues, keeping the replayed prompt/tool baseline."""
|
|
363
|
+
if self._active_run is not None:
|
|
364
|
+
raise RuntimeError("Agent is already processing. Wait for completion before resetting.")
|
|
365
|
+
baseline = get_current_system_message(self._state.messages)
|
|
366
|
+
self._state.messages = [baseline] if baseline else []
|
|
367
|
+
self._state.is_streaming = False
|
|
368
|
+
self._state.streaming_message = None
|
|
369
|
+
self._state.pending_tool_calls = frozenset()
|
|
370
|
+
self._state.error_message = None
|
|
371
|
+
self.clear_all_queues()
|
|
372
|
+
|
|
373
|
+
# -- running ----------------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
async def prompt(self, input: str | Any | Sequence[Any], images: Sequence[ImageContent] | None = None) -> None:
|
|
376
|
+
"""Start a run from text (with optional images), a message, or a list of messages."""
|
|
377
|
+
if self._active_run is not None:
|
|
378
|
+
raise RuntimeError(
|
|
379
|
+
"Agent is already processing a prompt. Use steer() or follow_up() to queue messages, "
|
|
380
|
+
"or wait for completion."
|
|
381
|
+
)
|
|
382
|
+
await self._run_prompt_messages(self._normalize_prompt_input(input, images))
|
|
383
|
+
|
|
384
|
+
async def continue_(self) -> None:
|
|
385
|
+
"""Continue from the current transcript (``continue`` is a Python keyword).
|
|
386
|
+
|
|
387
|
+
A non-assistant tail is retried. An assistant tail falls back to one queued steering
|
|
388
|
+
batch, then one queued follow-up batch.
|
|
389
|
+
"""
|
|
390
|
+
if self._active_run is not None:
|
|
391
|
+
raise RuntimeError("Agent is already processing. Wait for completion before continuing.")
|
|
392
|
+
messages = self._state.messages
|
|
393
|
+
if not messages or all(getattr(message, "role", None) == "system" for message in messages):
|
|
394
|
+
raise RuntimeError("No messages to continue from")
|
|
395
|
+
if getattr(messages[-1], "role", None) == "assistant":
|
|
396
|
+
steering = self._steering_queue.drain()
|
|
397
|
+
if steering:
|
|
398
|
+
await self._run_prompt_messages(steering, skip_initial_steering_poll=True)
|
|
399
|
+
return
|
|
400
|
+
follow_ups = self._follow_up_queue.drain()
|
|
401
|
+
if follow_ups:
|
|
402
|
+
await self._run_prompt_messages(follow_ups)
|
|
403
|
+
return
|
|
404
|
+
raise RuntimeError("Cannot continue from message role: assistant")
|
|
405
|
+
await self._run_continuation()
|
|
406
|
+
|
|
407
|
+
#: Alias of :meth:`continue_`.
|
|
408
|
+
resume = continue_
|
|
409
|
+
|
|
410
|
+
async def events(
|
|
411
|
+
self, input: str | Any | Sequence[Any], images: Sequence[ImageContent] | None = None
|
|
412
|
+
) -> AsyncIterator[AgentEvent]:
|
|
413
|
+
"""Run a prompt and yield its events as an async iterator.
|
|
414
|
+
|
|
415
|
+
Convenience over :meth:`subscribe` + :meth:`prompt`; state reduction happens
|
|
416
|
+
before each event is yielded, and the run waits for the consumer (the same
|
|
417
|
+
barrier semantics as an awaited listener).
|
|
418
|
+
"""
|
|
419
|
+
loop = asyncio.get_running_loop()
|
|
420
|
+
queue: asyncio.Queue[tuple[AgentEvent, asyncio.Future[None]] | None] = asyncio.Queue()
|
|
421
|
+
closed = False
|
|
422
|
+
|
|
423
|
+
async def listener(event: AgentEvent, _signal: AbortSignal) -> None:
|
|
424
|
+
if closed:
|
|
425
|
+
return
|
|
426
|
+
consumed: asyncio.Future[None] = loop.create_future()
|
|
427
|
+
queue.put_nowait((event, consumed))
|
|
428
|
+
await consumed
|
|
429
|
+
|
|
430
|
+
unsubscribe = self.subscribe(listener)
|
|
431
|
+
|
|
432
|
+
async def run() -> None:
|
|
433
|
+
try:
|
|
434
|
+
await self.prompt(input, images)
|
|
435
|
+
finally:
|
|
436
|
+
queue.put_nowait(None)
|
|
437
|
+
|
|
438
|
+
task = asyncio.ensure_future(run())
|
|
439
|
+
try:
|
|
440
|
+
while True:
|
|
441
|
+
item = await queue.get()
|
|
442
|
+
if item is None:
|
|
443
|
+
break
|
|
444
|
+
event, consumed = item
|
|
445
|
+
try:
|
|
446
|
+
yield event
|
|
447
|
+
finally:
|
|
448
|
+
if not consumed.done():
|
|
449
|
+
consumed.set_result(None)
|
|
450
|
+
await task
|
|
451
|
+
finally:
|
|
452
|
+
closed = True
|
|
453
|
+
unsubscribe()
|
|
454
|
+
while not queue.empty():
|
|
455
|
+
pending = queue.get_nowait()
|
|
456
|
+
if pending is not None and not pending[1].done():
|
|
457
|
+
pending[1].set_result(None)
|
|
458
|
+
if not task.done():
|
|
459
|
+
self.abort()
|
|
460
|
+
await asyncio.gather(task, return_exceptions=True)
|
|
461
|
+
|
|
462
|
+
def _normalize_prompt_input(self, input: Any, images: Sequence[ImageContent] | None) -> list[Any]:
|
|
463
|
+
if isinstance(input, (list, tuple)):
|
|
464
|
+
return _coerce_messages(list(input))
|
|
465
|
+
if not isinstance(input, str):
|
|
466
|
+
return _coerce_messages([input])
|
|
467
|
+
content: list[TextContent | ImageContent] = [TextContent(input)]
|
|
468
|
+
if images:
|
|
469
|
+
content.extend(images)
|
|
470
|
+
return [UserMessage(content, timestamp=now_ms())]
|
|
471
|
+
|
|
472
|
+
async def _run_prompt_messages(self, messages: list[Any], *, skip_initial_steering_poll: bool = False) -> None:
|
|
473
|
+
async def executor(signal: AbortSignal) -> None:
|
|
474
|
+
await run_agent_loop(
|
|
475
|
+
messages,
|
|
476
|
+
self._context_snapshot(),
|
|
477
|
+
self._loop_config(skip_initial_steering_poll),
|
|
478
|
+
self._process_event,
|
|
479
|
+
signal,
|
|
480
|
+
self.stream_function,
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
await self._run_with_lifecycle(executor)
|
|
484
|
+
|
|
485
|
+
async def _run_continuation(self) -> None:
|
|
486
|
+
async def executor(signal: AbortSignal) -> None:
|
|
487
|
+
await run_agent_loop_continue(
|
|
488
|
+
self._context_snapshot(), self._loop_config(), self._process_event, signal, self.stream_function
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
await self._run_with_lifecycle(executor)
|
|
492
|
+
|
|
493
|
+
def _context_snapshot(self) -> AgentContext:
|
|
494
|
+
return AgentContext(messages=list(self._state.messages), tools=list(self._state.tools))
|
|
495
|
+
|
|
496
|
+
def _loop_config(self, skip_initial_steering_poll: bool = False) -> AgentLoopConfig:
|
|
497
|
+
skip = [skip_initial_steering_poll]
|
|
498
|
+
|
|
499
|
+
async def get_steering_messages() -> list[Any]:
|
|
500
|
+
if skip[0]:
|
|
501
|
+
skip[0] = False
|
|
502
|
+
return []
|
|
503
|
+
return self._steering_queue.drain()
|
|
504
|
+
|
|
505
|
+
async def get_follow_up_messages() -> list[Any]:
|
|
506
|
+
return self._follow_up_queue.drain()
|
|
507
|
+
|
|
508
|
+
prepare_next_turn: Any = None
|
|
509
|
+
if self.prepare_next_turn_with_context is not None or self.prepare_next_turn is not None:
|
|
510
|
+
|
|
511
|
+
async def prepare(context: AgentTurnContext) -> AgentLoopTurnUpdate | None:
|
|
512
|
+
if self.prepare_next_turn_with_context is not None:
|
|
513
|
+
return await maybe_await(self.prepare_next_turn_with_context(context, self.signal))
|
|
514
|
+
assert self.prepare_next_turn is not None
|
|
515
|
+
return await maybe_await(self.prepare_next_turn(self.signal))
|
|
516
|
+
|
|
517
|
+
prepare_next_turn = prepare
|
|
518
|
+
|
|
519
|
+
stream_options: dict[str, Any] = dict(self.stream_options)
|
|
520
|
+
for key, value in (
|
|
521
|
+
("session_id", self.session_id),
|
|
522
|
+
("on_payload", self.on_payload),
|
|
523
|
+
("on_response", self.on_response),
|
|
524
|
+
("on_provider_stream_event", self.on_provider_stream_event),
|
|
525
|
+
("transport", self.transport),
|
|
526
|
+
("thinking_budgets", self.thinking_budgets),
|
|
527
|
+
("max_retry_delay_ms", self.max_retry_delay_ms),
|
|
528
|
+
):
|
|
529
|
+
if value is not None:
|
|
530
|
+
stream_options[key] = value
|
|
531
|
+
|
|
532
|
+
thinking_level = self._state.thinking_level
|
|
533
|
+
return AgentLoopConfig(
|
|
534
|
+
model=self._state.model,
|
|
535
|
+
convert_to_llm=self.convert_to_llm,
|
|
536
|
+
reasoning=None if thinking_level == "off" else thinking_level,
|
|
537
|
+
stream_options=stream_options,
|
|
538
|
+
transform_context=self.transform_context,
|
|
539
|
+
get_api_key=self.get_api_key,
|
|
540
|
+
finish_turn=self.finish_turn,
|
|
541
|
+
prepare_request=self.prepare_request,
|
|
542
|
+
prepare_next_turn=prepare_next_turn,
|
|
543
|
+
get_steering_messages=get_steering_messages,
|
|
544
|
+
get_follow_up_messages=get_follow_up_messages,
|
|
545
|
+
tool_execution=self.tool_execution,
|
|
546
|
+
before_tool_call=self.before_tool_call,
|
|
547
|
+
after_tool_call=self.after_tool_call,
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
async def _run_with_lifecycle(self, executor: Callable[[AbortSignal], Awaitable[None]]) -> None:
|
|
551
|
+
if self._active_run is not None:
|
|
552
|
+
raise RuntimeError("Agent is already processing.")
|
|
553
|
+
controller = AbortController()
|
|
554
|
+
self._active_run = _ActiveRun(asyncio.Event(), controller)
|
|
555
|
+
self._state.is_streaming = True
|
|
556
|
+
self._state.streaming_message = None
|
|
557
|
+
self._state.error_message = None
|
|
558
|
+
try:
|
|
559
|
+
await executor(controller.signal)
|
|
560
|
+
except Exception as error:
|
|
561
|
+
await self._handle_run_failure(error, controller.signal.aborted)
|
|
562
|
+
finally:
|
|
563
|
+
self._finish_run()
|
|
564
|
+
|
|
565
|
+
async def _handle_run_failure(self, error: BaseException, aborted: bool) -> None:
|
|
566
|
+
model = self._state.model
|
|
567
|
+
failure = AssistantMessage(
|
|
568
|
+
[TextContent("")],
|
|
569
|
+
api=model.api,
|
|
570
|
+
provider=model.provider,
|
|
571
|
+
model=model.id,
|
|
572
|
+
stop_reason="aborted" if aborted else "error",
|
|
573
|
+
error_message=str(error) or type(error).__name__,
|
|
574
|
+
timestamp=now_ms(),
|
|
575
|
+
)
|
|
576
|
+
await self._process_event(MessageStartEvent(failure))
|
|
577
|
+
await self._process_event(MessageEndEvent(failure))
|
|
578
|
+
await self._process_event(TurnEndEvent(failure, []))
|
|
579
|
+
await self._process_event(AgentEndEvent([failure]))
|
|
580
|
+
|
|
581
|
+
def _finish_run(self) -> None:
|
|
582
|
+
self._state.is_streaming = False
|
|
583
|
+
self._state.streaming_message = None
|
|
584
|
+
self._state.pending_tool_calls = frozenset()
|
|
585
|
+
run = self._active_run
|
|
586
|
+
self._active_run = None
|
|
587
|
+
if run is not None:
|
|
588
|
+
run.done.set()
|
|
589
|
+
|
|
590
|
+
async def _process_event(self, event: AgentEvent) -> None:
|
|
591
|
+
"""Reduce internal state for a loop event, then await listeners in order."""
|
|
592
|
+
state = self._state
|
|
593
|
+
kind = event.type
|
|
594
|
+
if kind == "message_start" or kind == "message_update":
|
|
595
|
+
state.streaming_message = event.message # type: ignore[union-attr]
|
|
596
|
+
elif kind == "message_end":
|
|
597
|
+
state.streaming_message = None
|
|
598
|
+
state._messages.append(event.message) # type: ignore[union-attr]
|
|
599
|
+
elif kind == "tool_execution_start":
|
|
600
|
+
state.pending_tool_calls = state.pending_tool_calls | {event.tool_call_id} # type: ignore[union-attr]
|
|
601
|
+
elif kind == "tool_execution_end":
|
|
602
|
+
state.pending_tool_calls = state.pending_tool_calls - {event.tool_call_id} # type: ignore[union-attr]
|
|
603
|
+
elif kind == "turn_end":
|
|
604
|
+
message = event.message # type: ignore[union-attr]
|
|
605
|
+
if getattr(message, "role", None) == "assistant" and message.error_message:
|
|
606
|
+
state.error_message = message.error_message
|
|
607
|
+
elif kind == "agent_end":
|
|
608
|
+
state.streaming_message = None
|
|
609
|
+
|
|
610
|
+
run = self._active_run
|
|
611
|
+
if run is None:
|
|
612
|
+
raise RuntimeError("Agent listener invoked outside active run")
|
|
613
|
+
signal = run.abort_controller.signal
|
|
614
|
+
for listener in tuple(self._listeners):
|
|
615
|
+
result = listener(event, signal)
|
|
616
|
+
if result is not None:
|
|
617
|
+
await result
|