pi-python-core 0.10.2__py3-none-any.whl → 0.12.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_python/__init__.py CHANGED
@@ -1,6 +1,7 @@
1
1
  """Embeddable asyncio agent core with optional model providers."""
2
2
 
3
3
  from .models import ModelInfo, ModelCatalog
4
+ from .identity import ClientIdentity, ClientAttribution, format_user_agent, get_identity_headers
4
5
  from ._version import __version__ as __version__
5
6
  from .agent import Agent, AgentStateView, RunResult
6
7
  from .cancellation import CancelToken
@@ -81,6 +82,10 @@ from .transcript import (
81
82
  from .estimate import clamp_max_tokens_to_context, estimate_context_tokens
82
83
 
83
84
  __all__ = [
85
+ "ClientIdentity",
86
+ "ClientAttribution",
87
+ "format_user_agent",
88
+ "get_identity_headers",
84
89
  "ModelInfo",
85
90
  "ModelCatalog",
86
91
  "Agent",
pi_python/_version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.10.2"
1
+ __version__ = "0.12.0"
pi_python/agent.py CHANGED
@@ -5,10 +5,12 @@ Execution behavior is adapted from Pi; see NOTICE.
5
5
  """
6
6
 
7
7
  from __future__ import annotations
8
+ from .identity import ClientIdentity, validate_identity
8
9
  import asyncio
9
10
  from copy import copy, deepcopy
10
11
  from dataclasses import dataclass
11
- from typing import Any, Callable
12
+ from typing import TYPE_CHECKING, Any, Callable
13
+ from collections.abc import Sequence
12
14
  from uuid import uuid4
13
15
 
14
16
  from .cancellation import CancelToken
@@ -42,6 +44,9 @@ from .sync import run_sync
42
44
  from .tools import Tool, ToolInvocation
43
45
  from .transcript import current_system_message
44
46
 
47
+ if TYPE_CHECKING:
48
+ from .core import Hooks as CoreHooks, Tool as CoreTool
49
+
45
50
 
46
51
  @dataclass(frozen=True)
47
52
  class AgentStateView:
@@ -69,13 +74,16 @@ class Agent:
69
74
  thinking_budgets: dict[str, int] | None = None,
70
75
  transport: str | None = None,
71
76
  session_id: str | None = None,
77
+ identity: ClientIdentity | None = None,
78
+ auto_session_id: bool = True,
79
+ cache_session_id: str | None = None,
72
80
  get_api_key: Callable | None = None,
73
81
  on_payload: Callable | None = None,
74
82
  on_response: Callable | None = None,
75
83
  on_provider_stream_event: Callable | None = None,
76
- tools: list[Tool] | None = None,
84
+ tools: Sequence[Tool | CoreTool] | None = None,
77
85
  messages: list[Message] | None = None,
78
- hooks: Hooks | None = None,
86
+ hooks: Hooks | CoreHooks | None = None,
79
87
  limits: RunLimits | None = None,
80
88
  execution_mode: str = "parallel",
81
89
  steering_mode: str = "one_at_a_time",
@@ -87,13 +95,37 @@ class Agent:
87
95
  if type(include_tool_origin) is not bool:
88
96
  raise ConfigurationError("include_tool_origin must be a boolean")
89
97
  self.include_tool_origin = include_tool_origin
98
+ self._core_contract = False
99
+ self._initial_cancel: CancelToken | None = None
100
+ self.identity = identity
101
+ if type(auto_session_id) is not bool:
102
+ raise ConfigurationError("auto_session_id must be a boolean")
103
+ if cache_session_id is not None:
104
+ if (
105
+ not isinstance(cache_session_id, str)
106
+ or not 1 <= len(cache_session_id) <= 64
107
+ or any(not "!" <= char <= "~" for char in cache_session_id)
108
+ ):
109
+ raise ConfigurationError(
110
+ "cache_session_id must contain 1–64 printable ASCII characters without spaces"
111
+ )
112
+ if not auto_session_id:
113
+ raise ConfigurationError("cache_session_id requires auto_session_id=True")
114
+ self._auto_session_id = auto_session_id
115
+ self._cache_session_id = cache_session_id
90
116
  self._queues = MessageQueues(steering_mode, follow_up_mode)
91
117
  if provider is not None and stream_fn is not None:
92
118
  raise ConfigurationError("Pass provider or stream_fn, not both")
93
119
  self.provider = provider or (
94
120
  FunctionProvider(stream_fn) if stream_fn else DefaultProvider()
95
121
  )
96
- self.hooks = copy(hooks) if hooks else Hooks()
122
+ self.hooks: Hooks
123
+ if isinstance(hooks, Hooks):
124
+ self.hooks = copy(hooks)
125
+ elif hooks is not None:
126
+ self.hooks = hooks.to_runtime()
127
+ else:
128
+ self.hooks = Hooks()
97
129
  for name, callback in (
98
130
  ("get_api_key", get_api_key),
99
131
  ("on_payload", on_payload),
@@ -115,7 +147,8 @@ class Agent:
115
147
  self.execution_mode = execution_mode
116
148
  self._get_steering_messages: Callable | None = None
117
149
  self._get_follow_up_messages: Callable | None = None
118
- self._defaults = AgentConfigUpdate(tools or [], model, options or {})
150
+ runtime_tools: list[Tool] = [getattr(t, "to_runtime", lambda t=t: t)() for t in tools or []]
151
+ self._defaults = AgentConfigUpdate(runtime_tools, model, options or {})
119
152
  self._validate_update(self._defaults)
120
153
  self._defaults = deepcopy(self._defaults)
121
154
  self._messages = deepcopy(messages or [])
@@ -126,7 +159,7 @@ class Agent:
126
159
  self._messages.insert(
127
160
  0,
128
161
  SystemMessage(
129
- system_prompt, tools_added=[t.declaration() for t in tools or []], timestamp=0
162
+ system_prompt, tools_added=[t.declaration() for t in runtime_tools], timestamp=0
130
163
  ),
131
164
  )
132
165
  validate_history(self._messages)
@@ -148,6 +181,32 @@ class Agent:
148
181
  self._settled = asyncio.Event()
149
182
  self._settled.set()
150
183
 
184
+ @property
185
+ def identity(self) -> ClientIdentity | None:
186
+ return deepcopy(self._identity)
187
+
188
+ @identity.setter
189
+ def identity(self, value: ClientIdentity | None) -> None:
190
+ validate_identity(value)
191
+ self._identity = deepcopy(value)
192
+
193
+ @property
194
+ def cache_session_id(self) -> str | None:
195
+ """Agent-managed Codex cache fallback, independent of explicit session_id.
196
+
197
+ Save this alongside history and restore it with cache_session_id=... .
198
+ Reading it does not enable connection reuse. Explicit session_id takes
199
+ precedence on requests; cache_retention='none' suppresses both on the wire.
200
+ """
201
+ if (
202
+ not self._auto_session_id
203
+ or getattr(self.provider, "supports_agent_cache_session", False) is not True
204
+ ):
205
+ return None
206
+ if self._cache_session_id is None:
207
+ self._cache_session_id = str(uuid4())
208
+ return self._cache_session_id
209
+
151
210
  @property
152
211
  def steering_mode(self) -> str:
153
212
  return self._queues.steering_mode
@@ -265,6 +324,7 @@ class Agent:
265
324
  raise AgentBusyError("Cannot reset while running")
266
325
  baseline = current_system_message(self._messages)
267
326
  self._messages = [baseline] if baseline else []
327
+ self._cache_session_id = None
268
328
  self._last_error = None
269
329
  self._run = None
270
330
  self.clear_all_queues()
pi_python/core.py ADDED
@@ -0,0 +1,538 @@
1
+ """Pi-shaped contracts over the shared execution engine.
2
+
3
+ Names use snake_case; messages retain Python's documented seconds/enum mapping.
4
+ The root Agent is the research convenience API. This module has no automatic
5
+ cache session, failed-assistant retry, or implicit result-string hook protocol.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from copy import copy, deepcopy
11
+ from dataclasses import asdict, dataclass, field, fields, replace
12
+ from typing import Any, Callable
13
+ import inspect
14
+
15
+ from .agent import Agent as ResearchAgent
16
+ from .cancellation import CancelToken
17
+ from .errors import ConfigurationError, InvalidContinuationError
18
+ from .hooks import Hooks as RuntimeHooks, RunContext, TurnUpdate as RuntimeUpdate
19
+ from .messages import AssistantMessage, Message, ToolResultMessage, _blocks, message_to_dict
20
+ from .models import ModelInfo
21
+ from .provider import ModelRequest
22
+ from .tools import Tool as RuntimeTool, ToolContext, ToolResult, invoke
23
+
24
+
25
+ @dataclass
26
+ class AgentContext:
27
+ messages: list[Message] = field(default_factory=list)
28
+ tools: list[Any] = field(default_factory=list)
29
+
30
+
31
+ @dataclass
32
+ class PrepareRequestContext:
33
+ context: AgentContext
34
+ model: str | ModelInfo
35
+ thinking_level: str
36
+
37
+
38
+ @dataclass
39
+ class AgentTurnContext:
40
+ message: AssistantMessage | None
41
+ tool_results: list[ToolResultMessage]
42
+ context: AgentContext
43
+ new_messages: list[Message]
44
+
45
+
46
+ @dataclass
47
+ class AgentRequestUpdate:
48
+ context: AgentContext | None = None
49
+ model: str | ModelInfo | None = None
50
+ thinking_level: str | None = None
51
+
52
+
53
+ @dataclass
54
+ class AgentLoopTurnUpdate(AgentRequestUpdate):
55
+ messages: list[Message] = field(default_factory=list)
56
+
57
+
58
+ @dataclass
59
+ class BeforeToolCallContext:
60
+ assistant_message: AssistantMessage | None
61
+ tool_call: Any
62
+ args: Any
63
+ context: AgentContext
64
+
65
+
66
+ @dataclass
67
+ class AfterToolCallContext(BeforeToolCallContext):
68
+ result: ToolResult
69
+ is_error: bool
70
+
71
+
72
+ def _context(value: RunContext) -> AgentContext:
73
+ return AgentContext(
74
+ deepcopy(value.messages), deepcopy([getattr(t, "_core_tool", t) for t in value.tools])
75
+ )
76
+
77
+
78
+ def _turn(value: RunContext) -> AgentTurnContext:
79
+ return AgentTurnContext(value.message, value.tool_results, _context(value), value.new_messages)
80
+
81
+
82
+ def _result(value: Any) -> ToolResult:
83
+ if isinstance(value, ToolResult):
84
+ return value
85
+ if not isinstance(value, dict):
86
+ raise ConfigurationError("Tool result must be ToolResult or a result object")
87
+ data = deepcopy(value)
88
+ if data["content"] and isinstance(data["content"][0], dict):
89
+ data["content"] = _blocks(data["content"], images=True)
90
+ return ToolResult(**data)
91
+
92
+
93
+ @dataclass
94
+ class Tool:
95
+ name: str
96
+ description: str
97
+ parameters: dict[str, Any]
98
+ execute: Callable
99
+ label: str = ""
100
+ output_schema: dict[str, Any] | None = None
101
+ execution_mode: str = "parallel"
102
+ prepare_arguments: Callable | None = None
103
+
104
+ def __post_init__(self) -> None:
105
+ if not self.label:
106
+ self.label = self.name
107
+
108
+ def __deepcopy__(self, memo: dict[int, Any]) -> Tool:
109
+ result = copy(self)
110
+ memo[id(self)] = result
111
+ result.parameters = deepcopy(self.parameters, memo)
112
+ result.output_schema = deepcopy(self.output_schema, memo)
113
+ return result
114
+
115
+ def to_runtime(self) -> RuntimeTool:
116
+ async def execute(args: Any, context: ToolContext) -> ToolResult:
117
+ active = True
118
+
119
+ async def update(value: Any) -> None:
120
+ if active:
121
+ await context.emit_update(asdict(_result(value)))
122
+
123
+ try:
124
+ return _result(
125
+ await invoke(self.execute, context.call_id, args, context.cancel, update)
126
+ )
127
+ finally:
128
+ active = False
129
+
130
+ tool = RuntimeTool(
131
+ self.name,
132
+ self.description,
133
+ self.parameters,
134
+ execute,
135
+ self.output_schema,
136
+ self.execution_mode,
137
+ self.prepare_arguments,
138
+ )
139
+ setattr(tool, "_core_tool", self)
140
+ return tool
141
+
142
+
143
+ def _tools(values: list[Any]) -> list[RuntimeTool]:
144
+ return [v.to_runtime() if isinstance(v, Tool) else v for v in values]
145
+
146
+
147
+ def _stream_fn(function: Callable) -> Callable:
148
+ async def stream(request: ModelRequest, signal: CancelToken) -> Any:
149
+ options = {**deepcopy(request.options), "signal": signal}
150
+ for name in ("api_key", "on_payload", "on_response", "on_provider_stream_event"):
151
+ value = getattr(request, name)
152
+ if value is not None:
153
+ options[name] = value
154
+ source = function(
155
+ request.model_info or request.model,
156
+ AgentContext(deepcopy(request.messages), deepcopy(request.tools)),
157
+ options,
158
+ )
159
+ if inspect.isawaitable(source):
160
+ source = await source
161
+ try:
162
+ async for event in source:
163
+ yield event
164
+ finally:
165
+ close = getattr(source, "aclose", None)
166
+ if close is not None:
167
+ await close()
168
+
169
+ return stream
170
+
171
+
172
+ @dataclass
173
+ class Hooks:
174
+ prepare_request: Callable | None = None
175
+ prepare_next_turn: Callable | None = None
176
+ transform_context: Callable | None = None
177
+ convert_to_llm: Callable | None = None
178
+ before_tool_call: Callable | None = None
179
+ after_tool_call: Callable | None = None
180
+ finish_turn: Callable | None = None
181
+ get_api_key: Callable | None = None
182
+ on_payload: Callable | None = None
183
+ on_response: Callable | None = None
184
+ on_provider_stream_event: Callable | None = None
185
+
186
+ @classmethod
187
+ def from_options(cls, options: dict[str, Any], hooks: Hooks | None = None) -> Hooks:
188
+ overrides = {f.name: options.pop(f.name) for f in fields(cls) if f.name in options}
189
+ return replace(hooks or cls(), **overrides)
190
+
191
+ def to_runtime(self) -> RuntimeHooks:
192
+ async def prepare(
193
+ name: str, value: RunContext, signal: CancelToken
194
+ ) -> RuntimeUpdate | None:
195
+ incoming = (
196
+ PrepareRequestContext(
197
+ _context(value), value.model, value.options.get("reasoning", "off")
198
+ )
199
+ if name == "prepare_request"
200
+ else _turn(value)
201
+ )
202
+ update = await invoke(getattr(self, name), incoming, signal)
203
+ if update is None:
204
+ return None
205
+ expected = AgentRequestUpdate if name == "prepare_request" else AgentLoopTurnUpdate
206
+ if isinstance(update, dict):
207
+ data = dict(update)
208
+ if isinstance(data.get("context"), dict):
209
+ data["context"] = AgentContext(**data["context"])
210
+ update = expected(**data)
211
+ if not isinstance(update, expected) or (
212
+ name == "prepare_request" and isinstance(update, AgentLoopTurnUpdate)
213
+ ):
214
+ raise ConfigurationError(f"{name} must return {expected.__name__}")
215
+ options = None
216
+ if update.thinking_level is not None:
217
+ options = {**value.options, "reasoning": update.thinking_level}
218
+ return RuntimeUpdate(
219
+ tools=_tools(update.context.tools) if update.context is not None else None,
220
+ model=update.model,
221
+ options=options,
222
+ context=update.context.messages if update.context is not None else None,
223
+ messages=update.messages if isinstance(update, AgentLoopTurnUpdate) else [],
224
+ )
225
+
226
+ async def before(call: Any, args: Any, ctx: ToolContext) -> ToolResult | None:
227
+ decision = await invoke(
228
+ self.before_tool_call,
229
+ BeforeToolCallContext(
230
+ ctx.assistant_message, call, args, _context(ctx.agent_context)
231
+ ),
232
+ ctx.cancel,
233
+ )
234
+ if decision is None:
235
+ return None
236
+ if not isinstance(decision, dict) or set(decision) - {"block", "reason", "terminate"}:
237
+ raise ConfigurationError(
238
+ "before_tool_call must return a block/reason/terminate object"
239
+ )
240
+ if decision.get("block"):
241
+ return ToolResult.text(
242
+ decision.get("reason") or "Tool call blocked",
243
+ is_error=True,
244
+ terminate=decision.get("terminate", False),
245
+ )
246
+ return None
247
+
248
+ async def after(call: Any, result: ToolResult, ctx: ToolContext) -> ToolResult:
249
+ update = await invoke(
250
+ self.after_tool_call,
251
+ AfterToolCallContext(
252
+ ctx.assistant_message,
253
+ call,
254
+ ctx.args,
255
+ _context(ctx.agent_context),
256
+ result,
257
+ result.is_error,
258
+ ),
259
+ ctx.cancel,
260
+ )
261
+ if update is None:
262
+ return result
263
+ allowed = {"content", "details", "structured_content", "is_error", "usage", "terminate"}
264
+ if not isinstance(update, dict) or set(update) - allowed:
265
+ raise ConfigurationError("after_tool_call must return a partial result object")
266
+ if "content" in update and "structured_content" not in update:
267
+ result.structured_content = None
268
+ for key, value in update.items():
269
+ if key == "content":
270
+ value = (
271
+ _blocks(value, images=True)
272
+ if value and isinstance(value[0], dict)
273
+ else value
274
+ )
275
+ setattr(result, key, deepcopy(value))
276
+ return result
277
+
278
+ async def finish(value: RunContext, signal: CancelToken) -> str | None:
279
+ decision = await invoke(self.finish_turn, _turn(value), signal)
280
+ if decision is None:
281
+ return None
282
+ if (
283
+ not isinstance(decision, dict)
284
+ or set(decision) != {"action"}
285
+ or decision["action"] not in {"end", "continue"}
286
+ ):
287
+ raise ConfigurationError("finish_turn must return {action: end|continue}")
288
+ return decision["action"]
289
+
290
+ return RuntimeHooks(
291
+ prepare_request=lambda ctx, signal: prepare("prepare_request", ctx, signal),
292
+ prepare_next_turn=lambda ctx, signal: prepare("prepare_next_turn", ctx, signal),
293
+ before_tool_call=before if self.before_tool_call else None,
294
+ after_tool_call=after if self.after_tool_call else None,
295
+ finish_turn=finish if self.finish_turn else None,
296
+ **{
297
+ name: getattr(self, name)
298
+ for name in (
299
+ "transform_context",
300
+ "convert_to_llm",
301
+ "get_api_key",
302
+ "on_payload",
303
+ "on_response",
304
+ "on_provider_stream_event",
305
+ )
306
+ },
307
+ )
308
+
309
+
310
+ def event_payload(run: Any, kind: str, call_id: str | None, data: dict[str, Any]) -> dict[str, Any]:
311
+ """Canonical payload produced at the source; never reconstruct from UI state."""
312
+ if kind == "agent_end":
313
+ return {"messages": [message_to_dict(m) for m in run.context.new_messages]}
314
+ if kind == "message_update":
315
+ return {
316
+ "message": data["partial"],
317
+ "assistant_message_event": data["assistant_message_event"],
318
+ }
319
+ if kind.startswith("tool_execution_"):
320
+ outcome = next(o for o in run.active_batch if o.call.id == call_id)
321
+ payload = {"tool_call_id": call_id, "tool_name": outcome.call.name}
322
+ if kind == "tool_execution_end":
323
+ payload.update(result=data["result"], is_error=outcome.result.is_error)
324
+ if "duration_ms" in data:
325
+ payload["duration_ms"] = data["duration_ms"]
326
+ else:
327
+ payload["args"] = deepcopy(outcome.original_arguments)
328
+ if kind == "tool_execution_update":
329
+ payload["partial_result"] = data["update"]
330
+ return payload
331
+ return data
332
+
333
+
334
+ @dataclass(frozen=True)
335
+ class AgentState:
336
+ messages: list[Message]
337
+ model: str | ModelInfo
338
+ thinking_level: str
339
+ tools: list[Any]
340
+ system_prompt: str
341
+ is_streaming: bool
342
+ streaming_message: Any
343
+ pending_tool_calls: frozenset[str]
344
+ error_message: str | None
345
+
346
+
347
+ class Agent:
348
+ """Pi-shaped session API. Research convenience methods remain on root Agent."""
349
+
350
+ def __init__(
351
+ self, *, hooks: Hooks | None = None, tools: list[Any] | None = None, **options: Any
352
+ ):
353
+ if "auto_session_id" in options or "cache_session_id" in options:
354
+ raise ConfigurationError("Automatic cache sessions belong to pi_python.Agent")
355
+ if options.get("stream_fn") is not None:
356
+ options["stream_fn"] = _stream_fn(options["stream_fn"])
357
+ next_with_context = options.pop("prepare_next_turn_with_context", None)
358
+ if next_with_context is not None:
359
+ options["prepare_next_turn"] = next_with_context
360
+ elif options.get("prepare_next_turn") is not None:
361
+ next_without_context = options["prepare_next_turn"]
362
+ options["prepare_next_turn"] = lambda context, signal: invoke(
363
+ next_without_context, signal
364
+ )
365
+ self._agent = ResearchAgent(
366
+ hooks=Hooks.from_options(options, hooks).to_runtime(),
367
+ tools=_tools(tools or []),
368
+ auto_session_id=False,
369
+ **options,
370
+ )
371
+ self._agent._core_contract = True
372
+
373
+ @property
374
+ def state(self) -> AgentState:
375
+ from .transcript import current_system_prompt
376
+
377
+ view = self._agent.state
378
+ config = (
379
+ self._agent._run.context
380
+ if self._agent._run and self._agent._running
381
+ else self._agent._defaults
382
+ )
383
+ messages = list(view.messages)
384
+ return AgentState(
385
+ messages,
386
+ deepcopy(config.model or "mock"),
387
+ (config.options or {}).get("reasoning", "off"),
388
+ deepcopy([getattr(t, "_core_tool", t) for t in config.tools or []]),
389
+ current_system_prompt(messages),
390
+ view.is_running,
391
+ view.partial_response,
392
+ frozenset(view.pending_calls),
393
+ view.last_error,
394
+ )
395
+
396
+ @property
397
+ def signal(self) -> CancelToken | None:
398
+ return self._agent.signal
399
+
400
+ def subscribe(self, listener: Callable) -> Callable:
401
+ return self._agent.subscribe(
402
+ lambda event: invoke(listener, {"type": event.type, **event.data}, self.signal)
403
+ )
404
+
405
+ async def prompt(self, message: Any) -> None:
406
+ await self._agent.prompt(message)
407
+
408
+ async def continue_run(self) -> None:
409
+ messages = self._agent.state.messages
410
+ if not messages or (isinstance(messages[-1], AssistantMessage) and not self._agent._queues):
411
+ raise InvalidContinuationError("Cannot continue from empty context or assistant")
412
+ await self._agent.continue_run()
413
+
414
+ def steer(self, message: Any) -> None:
415
+ self._agent.steer(message)
416
+
417
+ def follow_up(self, message: Any) -> None:
418
+ self._agent.follow_up(message)
419
+
420
+ def abort(self) -> None:
421
+ self._agent.abort()
422
+
423
+ async def wait_for_idle(self) -> None:
424
+ await self._agent.wait_for_idle()
425
+
426
+ def reset(self) -> None:
427
+ self._agent.reset()
428
+
429
+ async def aclose(self) -> None:
430
+ await self._agent.aclose()
431
+
432
+ async def __aenter__(self) -> Agent:
433
+ await self._agent.__aenter__()
434
+ return self
435
+
436
+ async def __aexit__(self, *args: Any) -> None:
437
+ await self.aclose()
438
+
439
+
440
+ @dataclass
441
+ class AgentLoopConfig(Hooks):
442
+ model: str | ModelInfo = "mock"
443
+ provider: Any = None
444
+ stream_fn: Callable | None = None
445
+ options: dict[str, Any] = field(default_factory=dict)
446
+ hooks: Hooks | None = None
447
+ thinking_level: str | None = None
448
+ tool_execution: str = "parallel"
449
+ get_steering_messages: Callable | None = None
450
+ get_follow_up_messages: Callable | None = None
451
+
452
+
453
+ async def _run(
454
+ prompts: Any,
455
+ context: AgentContext,
456
+ config: AgentLoopConfig,
457
+ emit: Callable | None,
458
+ cancel: CancelToken | None,
459
+ continuing: bool,
460
+ ) -> list[Message]:
461
+ from . import lowlevel
462
+
463
+ if continuing and (not context.messages or isinstance(context.messages[-1], AssistantMessage)):
464
+ raise InvalidContinuationError("Cannot continue from empty context or assistant")
465
+ options = deepcopy(config.options)
466
+ if config.thinking_level is not None:
467
+ options["reasoning"] = config.thinking_level
468
+ runtime_config = lowlevel.AgentLoopConfig(
469
+ provider=config.provider,
470
+ stream_fn=_stream_fn(config.stream_fn) if config.stream_fn else None,
471
+ model=config.model,
472
+ options=options,
473
+ hooks=Hooks.from_options(
474
+ {
475
+ f.name: getattr(config, f.name)
476
+ for f in fields(Hooks)
477
+ if getattr(config, f.name) is not None
478
+ },
479
+ config.hooks,
480
+ ).to_runtime(),
481
+ tool_execution=config.tool_execution,
482
+ get_steering_messages=config.get_steering_messages,
483
+ get_follow_up_messages=config.get_follow_up_messages,
484
+ )
485
+ setattr(runtime_config, "_core_contract", True)
486
+ runtime_context = lowlevel.AgentContext(context.messages, _tools(context.tools))
487
+
488
+ async def forward(event: Any) -> None:
489
+ await invoke(emit, {"type": event.type, **event.data})
490
+
491
+ result = await lowlevel._run(
492
+ prompts, runtime_context, runtime_config, forward, cancel, continuing
493
+ )
494
+ if continuing:
495
+ context.messages[:] = runtime_context.messages
496
+ return result
497
+
498
+
499
+ async def run_agent_loop(
500
+ prompts: list[Message],
501
+ context: AgentContext,
502
+ config: AgentLoopConfig,
503
+ emit: Callable | None = None,
504
+ cancel: CancelToken | None = None,
505
+ ) -> list[Message]:
506
+ return await _run(prompts, context, config, emit, cancel, False)
507
+
508
+
509
+ async def run_agent_loop_continue(
510
+ context: AgentContext,
511
+ config: AgentLoopConfig,
512
+ emit: Callable | None = None,
513
+ cancel: CancelToken | None = None,
514
+ ) -> list[Message]:
515
+ return await _run(None, context, config, emit, cancel, True)
516
+
517
+
518
+ def agent_loop(
519
+ prompts: list[Message],
520
+ context: AgentContext,
521
+ config: AgentLoopConfig,
522
+ cancel: CancelToken | None = None,
523
+ ) -> Any:
524
+ from .lowlevel import AgentEventStream
525
+
526
+ return AgentEventStream(
527
+ lambda emit: run_agent_loop(prompts, context, config, emit, cancel), maxsize=0
528
+ )
529
+
530
+
531
+ def agent_loop_continue(
532
+ context: AgentContext, config: AgentLoopConfig, cancel: CancelToken | None = None
533
+ ) -> Any:
534
+ from .lowlevel import AgentEventStream
535
+
536
+ return AgentEventStream(
537
+ lambda emit: run_agent_loop_continue(context, config, emit, cancel), maxsize=0
538
+ )