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.
Files changed (189) hide show
  1. pi_agent/__init__.py +127 -0
  2. pi_agent/agent.py +617 -0
  3. pi_agent/agent_loop.py +820 -0
  4. pi_agent/proxy.py +269 -0
  5. pi_agent/py.typed +0 -0
  6. pi_agent/stream_fn.py +27 -0
  7. pi_agent/tools.py +327 -0
  8. pi_agent/types.py +394 -0
  9. pi_ai/__init__.py +263 -0
  10. pi_ai/abort.py +315 -0
  11. pi_ai/api/__init__.py +1 -0
  12. pi_ai/api/anthropic_messages.py +1782 -0
  13. pi_ai/api/azure_openai_config.py +138 -0
  14. pi_ai/api/azure_openai_responses.py +245 -0
  15. pi_ai/api/bedrock_converse_stream.py +1598 -0
  16. pi_ai/api/classifier_shared.py +211 -0
  17. pi_ai/api/cloudflare.py +36 -0
  18. pi_ai/api/cloudflare_workers_ai_system_one.py +80 -0
  19. pi_ai/api/constrained_sampling.py +279 -0
  20. pi_ai/api/github_copilot_headers.py +30 -0
  21. pi_ai/api/google_generative_ai.py +138 -0
  22. pi_ai/api/google_shared.py +927 -0
  23. pi_ai/api/google_vertex.py +234 -0
  24. pi_ai/api/lazy.py +156 -0
  25. pi_ai/api/llama_cpp_classify.py +449 -0
  26. pi_ai/api/mistral_conversations.py +756 -0
  27. pi_ai/api/openai_codex_responses.py +1545 -0
  28. pi_ai/api/openai_completions.py +1594 -0
  29. pi_ai/api/openai_decisions.py +210 -0
  30. pi_ai/api/openai_prompt_cache.py +19 -0
  31. pi_ai/api/openai_responses.py +418 -0
  32. pi_ai/api/openai_responses_shared.py +840 -0
  33. pi_ai/api/openai_sdk.py +204 -0
  34. pi_ai/api/openrouter_images.py +153 -0
  35. pi_ai/api/pi_messages.py +449 -0
  36. pi_ai/api/registry.py +103 -0
  37. pi_ai/api/simple_options.py +119 -0
  38. pi_ai/api/system_one_shared.py +148 -0
  39. pi_ai/api/transform_messages.py +211 -0
  40. pi_ai/api/typesafe_system_one.py +43 -0
  41. pi_ai/auth/__init__.py +1 -0
  42. pi_ai/auth/context.py +47 -0
  43. pi_ai/auth/credential_store.py +208 -0
  44. pi_ai/auth/helpers.py +123 -0
  45. pi_ai/auth/oauth/__init__.py +5 -0
  46. pi_ai/auth/oauth/_common.py +454 -0
  47. pi_ai/auth/oauth/anthropic.py +283 -0
  48. pi_ai/auth/oauth/callback_server.py +339 -0
  49. pi_ai/auth/oauth/device_code.py +127 -0
  50. pi_ai/auth/oauth/github_copilot.py +508 -0
  51. pi_ai/auth/oauth/kimi_coding.py +283 -0
  52. pi_ai/auth/oauth/load.py +64 -0
  53. pi_ai/auth/oauth/meta.py +238 -0
  54. pi_ai/auth/oauth/openai_chatgpt.py +321 -0
  55. pi_ai/auth/oauth/openai_codex.py +391 -0
  56. pi_ai/auth/oauth/openrouter.py +173 -0
  57. pi_ai/auth/oauth/pkce.py +23 -0
  58. pi_ai/auth/oauth/radius.py +311 -0
  59. pi_ai/auth/oauth/xai.py +220 -0
  60. pi_ai/auth/resolve.py +190 -0
  61. pi_ai/auth/types.py +292 -0
  62. pi_ai/cli.py +265 -0
  63. pi_ai/compat.py +651 -0
  64. pi_ai/env_api_keys.py +137 -0
  65. pi_ai/event_stream.py +329 -0
  66. pi_ai/models.py +1191 -0
  67. pi_ai/models_store.py +121 -0
  68. pi_ai/oauth.py +90 -0
  69. pi_ai/providers/__init__.py +1 -0
  70. pi_ai/providers/all.py +154 -0
  71. pi_ai/providers/amazon_bedrock.py +116 -0
  72. pi_ai/providers/ant_ling.py +23 -0
  73. pi_ai/providers/anthropic.py +105 -0
  74. pi_ai/providers/azure.py +103 -0
  75. pi_ai/providers/baseten.py +23 -0
  76. pi_ai/providers/catalog.py +167 -0
  77. pi_ai/providers/cerebras.py +23 -0
  78. pi_ai/providers/cloudflare_ai_gateway.py +30 -0
  79. pi_ai/providers/cloudflare_auth.py +112 -0
  80. pi_ai/providers/cloudflare_stream.py +84 -0
  81. pi_ai/providers/cloudflare_workers_ai.py +26 -0
  82. pi_ai/providers/data/amazon-bedrock.json +1 -0
  83. pi_ai/providers/data/ant-ling.json +1 -0
  84. pi_ai/providers/data/anthropic.json +1 -0
  85. pi_ai/providers/data/azure.json +1 -0
  86. pi_ai/providers/data/baseten.json +1 -0
  87. pi_ai/providers/data/cerebras.json +1 -0
  88. pi_ai/providers/data/cloudflare-ai-gateway.json +1 -0
  89. pi_ai/providers/data/cloudflare-workers-ai.json +1 -0
  90. pi_ai/providers/data/deepseek.json +1 -0
  91. pi_ai/providers/data/fireworks.json +1 -0
  92. pi_ai/providers/data/github-copilot.json +1 -0
  93. pi_ai/providers/data/google-vertex.json +1 -0
  94. pi_ai/providers/data/google.json +1 -0
  95. pi_ai/providers/data/groq.json +1 -0
  96. pi_ai/providers/data/huggingface.json +1 -0
  97. pi_ai/providers/data/kimi-coding.json +1 -0
  98. pi_ai/providers/data/manifest.json +1 -0
  99. pi_ai/providers/data/meta.json +1 -0
  100. pi_ai/providers/data/minimax-cn.json +1 -0
  101. pi_ai/providers/data/minimax.json +1 -0
  102. pi_ai/providers/data/mistral.json +1 -0
  103. pi_ai/providers/data/moonshotai-cn.json +1 -0
  104. pi_ai/providers/data/moonshotai.json +1 -0
  105. pi_ai/providers/data/nvidia.json +1 -0
  106. pi_ai/providers/data/openai-codex.json +1 -0
  107. pi_ai/providers/data/openai.json +1 -0
  108. pi_ai/providers/data/opencode-go.json +1 -0
  109. pi_ai/providers/data/opencode.json +1 -0
  110. pi_ai/providers/data/openrouter.json +1 -0
  111. pi_ai/providers/data/qwen-token-plan-cn.json +1 -0
  112. pi_ai/providers/data/qwen-token-plan-individual.json +1 -0
  113. pi_ai/providers/data/qwen-token-plan.json +1 -0
  114. pi_ai/providers/data/radius.json +1 -0
  115. pi_ai/providers/data/together.json +1 -0
  116. pi_ai/providers/data/typesafe.json +1 -0
  117. pi_ai/providers/data/vercel-ai-gateway.json +1 -0
  118. pi_ai/providers/data/xai.json +1 -0
  119. pi_ai/providers/data/xiaomi-token-plan-ams.json +1 -0
  120. pi_ai/providers/data/xiaomi-token-plan-cn.json +1 -0
  121. pi_ai/providers/data/xiaomi-token-plan-sgp.json +1 -0
  122. pi_ai/providers/data/xiaomi.json +1 -0
  123. pi_ai/providers/data/zai-coding-cn.json +1 -0
  124. pi_ai/providers/data/zai.json +1 -0
  125. pi_ai/providers/deepseek.py +23 -0
  126. pi_ai/providers/faux.py +680 -0
  127. pi_ai/providers/fireworks.py +26 -0
  128. pi_ai/providers/github_copilot.py +46 -0
  129. pi_ai/providers/google.py +23 -0
  130. pi_ai/providers/google_vertex.py +131 -0
  131. pi_ai/providers/groq.py +23 -0
  132. pi_ai/providers/huggingface.py +23 -0
  133. pi_ai/providers/kimi_coding.py +32 -0
  134. pi_ai/providers/meta.py +32 -0
  135. pi_ai/providers/minimax.py +23 -0
  136. pi_ai/providers/minimax_cn.py +23 -0
  137. pi_ai/providers/mistral.py +23 -0
  138. pi_ai/providers/moonshotai.py +23 -0
  139. pi_ai/providers/moonshotai_cn.py +23 -0
  140. pi_ai/providers/nvidia.py +23 -0
  141. pi_ai/providers/openai.py +44 -0
  142. pi_ai/providers/openai_codex.py +26 -0
  143. pi_ai/providers/opencode.py +36 -0
  144. pi_ai/providers/opencode_go.py +33 -0
  145. pi_ai/providers/opencode_headers.py +60 -0
  146. pi_ai/providers/openrouter.py +145 -0
  147. pi_ai/providers/openrouter_api.py +122 -0
  148. pi_ai/providers/qwen_token_plan.py +23 -0
  149. pi_ai/providers/qwen_token_plan_cn.py +23 -0
  150. pi_ai/providers/qwen_token_plan_individual.py +23 -0
  151. pi_ai/providers/radius.py +146 -0
  152. pi_ai/providers/radius_config.py +136 -0
  153. pi_ai/providers/together.py +23 -0
  154. pi_ai/providers/typesafe.py +22 -0
  155. pi_ai/providers/vercel_ai_gateway.py +25 -0
  156. pi_ai/providers/xai.py +32 -0
  157. pi_ai/providers/xiaomi.py +23 -0
  158. pi_ai/providers/xiaomi_token_plan_ams.py +23 -0
  159. pi_ai/providers/xiaomi_token_plan_cn.py +23 -0
  160. pi_ai/providers/xiaomi_token_plan_sgp.py +23 -0
  161. pi_ai/providers/zai.py +23 -0
  162. pi_ai/providers/zai_coding_cn.py +23 -0
  163. pi_ai/py.typed +0 -0
  164. pi_ai/serialization.py +477 -0
  165. pi_ai/session_resources.py +33 -0
  166. pi_ai/types.py +1003 -0
  167. pi_ai/utils/__init__.py +1 -0
  168. pi_ai/utils/callbacks.py +15 -0
  169. pi_ai/utils/diagnostics.py +70 -0
  170. pi_ai/utils/error_body.py +89 -0
  171. pi_ai/utils/estimate.py +125 -0
  172. pi_ai/utils/fastjson.py +63 -0
  173. pi_ai/utils/http.py +409 -0
  174. pi_ai/utils/json_parse.py +424 -0
  175. pi_ai/utils/misc.py +193 -0
  176. pi_ai/utils/model_operations.py +73 -0
  177. pi_ai/utils/models_error.py +35 -0
  178. pi_ai/utils/oauth_page.py +121 -0
  179. pi_ai/utils/overflow.py +87 -0
  180. pi_ai/utils/retry.py +193 -0
  181. pi_ai/utils/schema.py +283 -0
  182. pi_ai/utils/text.py +65 -0
  183. pi_ai/utils/transcript.py +271 -0
  184. pi_ai/utils/validation.py +813 -0
  185. pi_ai_python-1.3.0.dist-info/METADATA +167 -0
  186. pi_ai_python-1.3.0.dist-info/RECORD +189 -0
  187. pi_ai_python-1.3.0.dist-info/WHEEL +4 -0
  188. pi_ai_python-1.3.0.dist-info/entry_points.txt +2 -0
  189. 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