pi-python-core 0.10.1__py3-none-any.whl → 0.11.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
@@ -64,6 +64,7 @@ from .recovery import (
64
64
  )
65
65
  from .tools import (
66
66
  Tool,
67
+ ToolInvocation,
67
68
  ToolContext,
68
69
  ToolExecutor,
69
70
  ToolResult,
@@ -151,6 +152,7 @@ __all__ = [
151
152
  "is_recoverable_length",
152
153
  "is_retryable_error",
153
154
  "retry_delay",
155
+ "ToolInvocation",
154
156
  "ToolContext",
155
157
  "ToolExecutor",
156
158
  "ToolResult",
pi_python/_version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.10.1"
1
+ __version__ = "0.11.0"
pi_python/agent.py CHANGED
@@ -9,6 +9,7 @@ import asyncio
9
9
  from copy import copy, deepcopy
10
10
  from dataclasses import dataclass
11
11
  from typing import Any, Callable
12
+ from uuid import uuid4
12
13
 
13
14
  from .cancellation import CancelToken
14
15
  from .errors import (
@@ -29,6 +30,7 @@ from .messages import (
29
30
  TextContent,
30
31
  Message,
31
32
  SystemMessage,
33
+ ToolCall,
32
34
  UserMessage,
33
35
  validate_history,
34
36
  validate_json,
@@ -37,7 +39,7 @@ from .provider import Provider, DefaultProvider, FunctionProvider, has_default_s
37
39
  from .queues import MessageQueues
38
40
  from .run import CALLER_CANCELLED, Run, RunResult, merge
39
41
  from .sync import run_sync
40
- from .tools import Tool
42
+ from .tools import Tool, ToolInvocation
41
43
  from .transcript import current_system_message
42
44
 
43
45
 
@@ -67,6 +69,8 @@ class Agent:
67
69
  thinking_budgets: dict[str, int] | None = None,
68
70
  transport: str | None = None,
69
71
  session_id: str | None = None,
72
+ auto_session_id: bool = True,
73
+ cache_session_id: str | None = None,
70
74
  get_api_key: Callable | None = None,
71
75
  on_payload: Callable | None = None,
72
76
  on_response: Callable | None = None,
@@ -78,9 +82,28 @@ class Agent:
78
82
  execution_mode: str = "parallel",
79
83
  steering_mode: str = "one_at_a_time",
80
84
  follow_up_mode: str = "one_at_a_time",
85
+ include_tool_origin: bool = False,
81
86
  ):
82
87
  if execution_mode not in {"parallel", "sequential"}:
83
88
  raise ConfigurationError("Invalid execution mode")
89
+ if type(include_tool_origin) is not bool:
90
+ raise ConfigurationError("include_tool_origin must be a boolean")
91
+ self.include_tool_origin = include_tool_origin
92
+ if type(auto_session_id) is not bool:
93
+ raise ConfigurationError("auto_session_id must be a boolean")
94
+ if cache_session_id is not None:
95
+ if (
96
+ not isinstance(cache_session_id, str)
97
+ or not 1 <= len(cache_session_id) <= 64
98
+ or any(not "!" <= char <= "~" for char in cache_session_id)
99
+ ):
100
+ raise ConfigurationError(
101
+ "cache_session_id must contain 1–64 printable ASCII characters without spaces"
102
+ )
103
+ if not auto_session_id:
104
+ raise ConfigurationError("cache_session_id requires auto_session_id=True")
105
+ self._auto_session_id = auto_session_id
106
+ self._cache_session_id = cache_session_id
84
107
  self._queues = MessageQueues(steering_mode, follow_up_mode)
85
108
  if provider is not None and stream_fn is not None:
86
109
  raise ConfigurationError("Pass provider or stream_fn, not both")
@@ -142,6 +165,23 @@ class Agent:
142
165
  self._settled = asyncio.Event()
143
166
  self._settled.set()
144
167
 
168
+ @property
169
+ def cache_session_id(self) -> str | None:
170
+ """Agent-managed Codex cache fallback, independent of explicit session_id.
171
+
172
+ Save this alongside history and restore it with cache_session_id=... .
173
+ Reading it does not enable connection reuse. Explicit session_id takes
174
+ precedence on requests; cache_retention='none' suppresses both on the wire.
175
+ """
176
+ if (
177
+ not self._auto_session_id
178
+ or getattr(self.provider, "supports_agent_cache_session", False) is not True
179
+ ):
180
+ return None
181
+ if self._cache_session_id is None:
182
+ self._cache_session_id = str(uuid4())
183
+ return self._cache_session_id
184
+
145
185
  @property
146
186
  def steering_mode(self) -> str:
147
187
  return self._queues.steering_mode
@@ -204,6 +244,10 @@ class Agent:
204
244
  if self._running:
205
245
  self._updates.append(update)
206
246
  else:
247
+ # Preserve submission order after an application batch deferred updates.
248
+ for pending in self._updates:
249
+ merge(self._defaults, pending)
250
+ self._updates.clear()
207
251
  merge(self._defaults, update)
208
252
 
209
253
  @staticmethod
@@ -255,6 +299,7 @@ class Agent:
255
299
  raise AgentBusyError("Cannot reset while running")
256
300
  baseline = current_system_message(self._messages)
257
301
  self._messages = [baseline] if baseline else []
302
+ self._cache_session_id = None
258
303
  self._last_error = None
259
304
  self._run = None
260
305
  self.clear_all_queues()
@@ -332,6 +377,31 @@ class Agent:
332
377
  raise AgentBusyError("Agent already running")
333
378
  return await self._start(self._input(message))
334
379
 
380
+ async def execute_tools(
381
+ self, invocations: list[ToolInvocation], *, source: str | None = None
382
+ ) -> RunResult:
383
+ """Execute a nonempty batch without a model request or consuming queued input.
384
+
385
+ Shares the model tool path, limits and cancellation ownership. Results are
386
+ recorded as application feedback, never as assistant/tool-result pairs.
387
+ ``source`` is caller-supplied diagnostic metadata, not an identity claim.
388
+ """
389
+ self._usable()
390
+ if self._running:
391
+ raise AgentBusyError("Agent already running")
392
+ validate_history(self._messages)
393
+ if not isinstance(invocations, list) or not invocations:
394
+ raise ConfigurationError("execute_tools requires a nonempty list of ToolInvocation")
395
+ if source is not None and not isinstance(source, str):
396
+ raise ConfigurationError("source must be a string or None")
397
+ calls = []
398
+ for invocation in invocations:
399
+ if not isinstance(invocation, ToolInvocation):
400
+ raise ConfigurationError("Expected ToolInvocation")
401
+ invocation.__post_init__()
402
+ calls.append(ToolCall(str(uuid4()), invocation.name, deepcopy(invocation.arguments)))
403
+ return await self._start([], application_calls=calls, source=source)
404
+
335
405
  def prompt_sync(
336
406
  self, message: str | Message | list[Message], images: list[ImageContent] | None = None
337
407
  ) -> RunResult:
@@ -369,18 +439,32 @@ class Agent:
369
439
  return await self._start([])
370
440
 
371
441
  async def _start(
372
- self, pending: list[Message], skip_initial_steering: bool = False
442
+ self,
443
+ pending: list[Message],
444
+ skip_initial_steering: bool = False,
445
+ *,
446
+ application_calls: list[ToolCall] | None = None,
447
+ source: str | None = None,
373
448
  ) -> RunResult:
374
- if isinstance(self.provider, DefaultProvider) and not has_default_stream():
449
+ if (
450
+ application_calls is None
451
+ and isinstance(self.provider, DefaultProvider)
452
+ and not has_default_stream()
453
+ ):
375
454
  raise ConfigurationError(
376
455
  "No model provider: pass Agent(provider=...) or call set_default_stream_fn(...)"
377
456
  )
457
+ for update in self._updates:
458
+ merge(self._defaults, update)
459
+ self._updates.clear()
378
460
  self._running = True
379
461
  self._idle.clear()
380
462
  self._settled.clear()
381
463
  self._last_error = None
382
464
  self._events.failed = False
383
- run = self._run = Run(self, skip_initial_steering)
465
+ run = self._run = Run(
466
+ self, skip_initial_steering, application_calls=application_calls, source=source
467
+ )
384
468
  run.loop = asyncio.get_running_loop()
385
469
  # Driver starts with a checkpoint so cancellation before scheduling still finalizes.
386
470
  run.driver = asyncio.create_task(self._drive(run, pending))
pi_python/events.py CHANGED
@@ -16,7 +16,7 @@ from .messages import validate_json
16
16
  class Event:
17
17
  type: str
18
18
  run_id: str
19
- turn_id: int
19
+ turn_id: int | None
20
20
  sequence: int
21
21
  call_id: str | None = None
22
22
  data: dict[str, Any] = field(default_factory=dict)
@@ -50,8 +50,7 @@ def decode_event(value: str) -> Event:
50
50
  if (
51
51
  type(e.sequence) is not int
52
52
  or e.sequence < 1
53
- or type(e.turn_id) is not int
54
- or e.turn_id < 0
53
+ or (e.turn_id is not None and (type(e.turn_id) is not int or e.turn_id < 0))
55
54
  ):
56
55
  raise MessageValidationError("Invalid event sequence or turn")
57
56
  if e.call_id is not None and not isinstance(e.call_id, str):
@@ -85,7 +84,7 @@ class EventDispatcher:
85
84
  self,
86
85
  kind: str,
87
86
  run_id: str,
88
- turn_id: int,
87
+ turn_id: int | None,
89
88
  data: dict[str, Any],
90
89
  call_id: str | None,
91
90
  await_owned: Callable,
pi_python/features.py CHANGED
@@ -19,6 +19,8 @@ FEATURES = MCP_FEATURES | frozenset(
19
19
  "task-scope-v1",
20
20
  "loop-portal-v1",
21
21
  "nested-agent-ownership-v1",
22
+ "application-tool-batches-v1",
23
+ "codex-auto-cache-session-v1",
22
24
  "plugin-requires-v1",
23
25
  }
24
26
  )
pi_python/loop.py CHANGED
@@ -19,6 +19,7 @@ from .messages import (
19
19
  message_from_dict,
20
20
  message_to_dict,
21
21
  validate_history,
22
+ convert_application_tool_batch,
22
23
  )
23
24
  from .models import ModelInfo
24
25
  from .provider import ModelRequest
@@ -82,6 +83,12 @@ async def stream_response(run: Run) -> tuple[AssistantMessage, bool]:
82
83
  messages = await run.hook("transform_context", messages, run.token)
83
84
  if run.hooks.convert_to_llm:
84
85
  messages = await run.hook("convert_to_llm", deepcopy(messages))
86
+ messages = [
87
+ convert_application_tool_batch(m)
88
+ if isinstance(m, CustomMessage) and m.custom_type == "application_tool_batch"
89
+ else m
90
+ for m in messages
91
+ ]
85
92
  if any(isinstance(m, CustomMessage) for m in messages):
86
93
  raise UnsupportedCapabilityError("Custom messages require convert_to_llm")
87
94
  validate_history(messages)
@@ -98,6 +105,14 @@ async def stream_response(run: Run) -> tuple[AssistantMessage, bool]:
98
105
  deepcopy(context.options),
99
106
  model_info=deepcopy(model) if isinstance(model, ModelInfo) else None,
100
107
  )
108
+ if (
109
+ getattr(run.provider, "supports_agent_cache_session", False) is True
110
+ and not request.options.get("session_id")
111
+ and request.options.get("cache_retention") != "none"
112
+ ):
113
+ cache_session_id = run.agent.cache_session_id
114
+ if cache_session_id is not None:
115
+ request._set_cache_session_id(cache_session_id)
101
116
  if run.hooks.get_api_key:
102
117
  request.api_key = await run.hook(
103
118
  "get_api_key", getattr(run.provider, "name", request.model)
@@ -212,6 +227,8 @@ async def execute_batch(run: Run, outcomes: list[ToolOutcome]) -> None:
212
227
  agent_context=snapshot["context"],
213
228
  _on_reconciliation_required=lambda: run.require_reconciliation(),
214
229
  )
230
+ if run.application_batch:
231
+ contexts[id(outcome)]._set_origin(outcome.origin, outcome.source)
215
232
  return contexts[id(outcome)]
216
233
 
217
234
  def abort_before_start(outcome: ToolOutcome) -> bool:
@@ -300,6 +317,32 @@ async def execute_batch(run: Run, outcomes: list[ToolOutcome]) -> None:
300
317
  await run.commit_outcome(outcome)
301
318
 
302
319
 
320
+ def exceeds_tool_budget(run: Run, outcomes: list[ToolOutcome], used: int = 0) -> bool:
321
+ maximum = run.limits.max_tool_calls
322
+ if maximum is None or used + len(outcomes) <= maximum:
323
+ return False
324
+ for outcome in outcomes:
325
+ outcome.result = error_result("limit", "Entire tool batch exceeds remaining budget")
326
+ return True
327
+
328
+
329
+ async def run_application_batch(run: Run) -> tuple[str, str]:
330
+ run.checkpoint()
331
+ await run.emit("agent_start")
332
+ if exceeds_tool_budget(run, run.active_batch):
333
+ return "limit_reached", "max_tool_calls"
334
+ await execute_batch(run, run.active_batch)
335
+ if run.agent._unknown:
336
+ return "failed", "outcome_unknown"
337
+ if run.stuck():
338
+ run.fail("Tool did not stop after cancellation")
339
+ return "cancelled" if run.token.cancelled else "failed", "tool_not_stopped"
340
+ if run.token.cancelled:
341
+ status, reason, _ = run.cancelled_outcome()
342
+ return status, reason
343
+ return "completed", "stop"
344
+
345
+
303
346
  async def run_loop(run: Run, initial: list[Message]) -> tuple[str, str]:
304
347
  context = run.context
305
348
  assert context is not None
@@ -366,13 +409,8 @@ async def run_loop(run: Run, initial: list[Message]) -> tuple[str, str]:
366
409
  else:
367
410
  status, reason = "cancelled", run.token.reason or "aborted"
368
411
  elif outcomes:
369
- max_calls = run.limits.max_tool_calls
370
- if max_calls is not None and tool_count + len(outcomes) > max_calls:
412
+ if exceeds_tool_budget(run, outcomes, tool_count):
371
413
  status, reason = "limit_reached", "max_tool_calls"
372
- for outcome in outcomes:
373
- outcome.result = error_result(
374
- "limit", "Entire tool batch exceeds remaining budget"
375
- )
376
414
  elif message.stop_reason == "length":
377
415
  for outcome in outcomes:
378
416
  await run.emit(
pi_python/messages.py CHANGED
@@ -361,6 +361,63 @@ def message_from_dict(data: dict[str, Any]) -> Message:
361
361
  raise MessageValidationError(str(exc)) from exc
362
362
 
363
363
 
364
+ def convert_application_tool_batch(message: CustomMessage) -> UserMessage:
365
+ """Project built-in application feedback, never local details or raw results.
366
+
367
+ An allowlist also protects the default visibility boundary for imported records
368
+ with extra fields. Applications can override conversion with convert_to_llm.
369
+ """
370
+ data = message.data
371
+ if not isinstance(data, dict) or not isinstance(data.get("calls"), list):
372
+ raise MessageValidationError("Invalid application tool batch")
373
+ calls = []
374
+ for call in data["calls"]:
375
+ if not isinstance(call, dict):
376
+ raise MessageValidationError("Invalid application tool call record")
377
+ keys = (
378
+ "id",
379
+ "name",
380
+ "arguments",
381
+ "prepared_arguments",
382
+ "origin",
383
+ "source",
384
+ "execution_status",
385
+ "is_error",
386
+ "error_code",
387
+ "content",
388
+ )
389
+ try:
390
+ visible = {key: call[key] for key in keys}
391
+ except KeyError as exc:
392
+ raise MessageValidationError("Incomplete application tool call record") from exc
393
+ validate_json(visible)
394
+ if (
395
+ visible["origin"] != "application"
396
+ or not isinstance(visible["id"], str)
397
+ or not visible["id"]
398
+ or not isinstance(visible["name"], str)
399
+ or not visible["name"]
400
+ or not isinstance(visible["arguments"], dict)
401
+ or (
402
+ visible["prepared_arguments"] is not None
403
+ and not isinstance(visible["prepared_arguments"], dict)
404
+ )
405
+ or (visible["source"] is not None and not isinstance(visible["source"], str))
406
+ or type(visible["is_error"]) is not bool
407
+ or visible["execution_status"]
408
+ not in ("not_started", "running", "succeeded", "failed", "cancelled", "unknown")
409
+ or (visible["error_code"] is not None and not isinstance(visible["error_code"], str))
410
+ ):
411
+ raise MessageValidationError("Invalid application tool call record")
412
+ _blocks(visible["content"], images=True)
413
+ calls.append(visible)
414
+ return UserMessage(
415
+ "Application tool execution results (application-selected, not model tool calls):\n"
416
+ + json.dumps({"calls": calls}, ensure_ascii=False, allow_nan=False),
417
+ timestamp=message.timestamp,
418
+ )
419
+
420
+
364
421
  def validate_history(messages: list[Message]) -> None:
365
422
  pending: dict[str, str] = {}
366
423
  for message in messages:
pi_python/provider.py CHANGED
@@ -28,6 +28,20 @@ class ModelRequest:
28
28
  on_response: Callable | None = field(default=None, repr=False)
29
29
  on_provider_stream_event: Callable | None = field(default=None, repr=False)
30
30
 
31
+ def _set_cache_session_id(self, value: str) -> None:
32
+ # Per-request metadata, deliberately outside the public dataclass fields
33
+ # and options. It must never be interpreted as a transport session_id.
34
+ self._agent_cache_session_id = value
35
+
36
+ @property
37
+ def cache_session_id(self) -> str | None:
38
+ """Optional Agent-owned cache affinity, never a transport session.
39
+
40
+ Providers opt in with supports_agent_cache_session=True. This property
41
+ is not a dataclass field and does not change serialized request options.
42
+ """
43
+ return getattr(self, "_agent_cache_session_id", None)
44
+
31
45
 
32
46
  @dataclass
33
47
  class ModelEvent:
@@ -109,6 +123,10 @@ def has_default_stream() -> bool:
109
123
 
110
124
 
111
125
  class DefaultProvider:
126
+ @property
127
+ def supports_agent_cache_session(self) -> bool:
128
+ return getattr(_default_stream, "supports_agent_cache_session", False) is True
129
+
112
130
  @property
113
131
  def name(self) -> str:
114
132
  return getattr(_default_stream, "name", "custom")
@@ -318,6 +318,14 @@ class OpenAIProvider(RemoteProvider):
318
318
  position += 1
319
319
  return items, transcript
320
320
 
321
+ def _cache_key(self, request: ModelRequest) -> str | None:
322
+ if request.options.get("cache_retention", "short") == "none":
323
+ return None
324
+ key = request.options.get("session_id")
325
+ if not key and getattr(self, "supports_agent_cache_session", False) is True:
326
+ key = request.cache_session_id
327
+ return key[:64] if isinstance(key, str) else key
328
+
321
329
  def build_request(self, request: ModelRequest, chatgpt_sign_in: bool = False) -> dict[str, Any]:
322
330
  model = self.model_info(request)
323
331
  compat = model.compat
@@ -334,9 +342,7 @@ class OpenAIProvider(RemoteProvider):
334
342
  retention = options.get("cache_retention", "short")
335
343
  if retention not in {"none", "short", "long"}:
336
344
  raise ConfigurationError("Invalid cache_retention")
337
- key = options.get("session_id") if retention != "none" else None
338
- if isinstance(key, str) and len(key) > 64:
339
- key = key[:64]
345
+ key = self._cache_key(request)
340
346
  body: dict[str, Any] = {
341
347
  "model": request.model,
342
348
  "input": items,
@@ -436,13 +442,17 @@ class OpenAIProvider(RemoteProvider):
436
442
  "OpenAI-Beta": "responses=experimental",
437
443
  }
438
444
  )
439
- if (
440
- request.options.get("session_id")
441
- and request.options.get("cache_retention") != "none"
442
- ):
443
- headers["session-id"] = headers["x-client-request-id"] = request.options[
444
- "session_id"
445
- ][:64]
445
+ cache_key = self._cache_key(request)
446
+ if cache_key:
447
+ if request.options.get("session_id"):
448
+ # Preserve the existing precedence of explicit session_id.
449
+ headers["session-id"] = headers["x-client-request-id"] = cache_key
450
+ else:
451
+ # Automatic defaults must not overwrite application headers.
452
+ supplied = {name.lower() for name in headers}
453
+ for name in ("session-id", "x-client-request-id"):
454
+ if name not in supplied:
455
+ headers[name] = cache_key
446
456
  if (
447
457
  self.name == "openai"
448
458
  and request.options.get("session_id")
@@ -458,7 +468,8 @@ class OpenAIProvider(RemoteProvider):
458
468
  body = await self.payload(request, self.build_request(request, chatgpt))
459
469
  if transport == "websocket-cached" and not request.options.get("session_id"):
460
470
  raise ConfigurationError("websocket-cached requires session_id")
461
- # Pi caches a connection per session unless caching is disabled for the request.
471
+ # Only an application-supplied session_id can opt into connection reuse
472
+ # and response continuation. The Agent's cache fallback never reaches here.
462
473
  cached = (
463
474
  request.options.get("session_id")
464
475
  if request.options.get("cache_retention") != "none"
@@ -645,6 +656,7 @@ class OpenAIProvider(RemoteProvider):
645
656
  class OpenAICodexProvider(OpenAIProvider):
646
657
  name = "openai-codex"
647
658
  api = "openai-codex-responses"
659
+ supports_agent_cache_session = True
648
660
 
649
661
  def __init__(
650
662
  self, *, base_url: str = "https://chatgpt.com/backend-api/codex", **kwargs: Any
pi_python/run.py CHANGED
@@ -15,7 +15,9 @@ from .errors import ConfigurationError
15
15
  from .hooks import AgentConfigUpdate, Hooks, RunContext, TurnUpdate
16
16
  from .messages import (
17
17
  AssistantMessage,
18
+ CustomMessage,
18
19
  Message,
20
+ ToolCall,
19
21
  ToolResultMessage,
20
22
  message_to_dict,
21
23
  validate_history,
@@ -23,7 +25,7 @@ from .messages import (
23
25
  from .errors import SubscriptionError
24
26
  from .tools import ToolOutcome, error_result, invoke
25
27
  from .transcript import declare_tool_changes
26
- from .loop import abort_error, failure_message, run_loop
28
+ from .loop import abort_error, failure_message, run_loop, run_application_batch
27
29
 
28
30
  if TYPE_CHECKING:
29
31
  from .agent import Agent
@@ -58,11 +60,24 @@ def merge(target: Any, update: AgentConfigUpdate) -> None:
58
60
  class Run:
59
61
  """State that lives for one run. The Agent keeps everything that outlives it."""
60
62
 
61
- def __init__(self, agent: Agent, skip_initial_steering: bool = False):
63
+ def __init__(
64
+ self,
65
+ agent: Agent,
66
+ skip_initial_steering: bool = False,
67
+ *,
68
+ application_calls: list[ToolCall] | None = None,
69
+ source: str | None = None,
70
+ ):
62
71
  self.agent = agent
63
72
  self.token = CancelToken()
64
73
  self.id = str(uuid4())
65
74
  self.turn = 0
75
+ self.application_batch = application_calls is not None
76
+ self.origin = "application" if self.application_batch else "model"
77
+ self.source = source
78
+ self.include_tool_origin = agent.include_tool_origin
79
+ self._limits = agent.limits
80
+ self._execution_mode = agent.execution_mode
66
81
  defaults = agent._defaults
67
82
  self.context = RunContext(
68
83
  deepcopy(agent._messages),
@@ -74,8 +89,16 @@ class Run:
74
89
  self.skip_initial_steering = skip_initial_steering
75
90
  self.partial: Any = None
76
91
  self.started = False # message_start was published for the current response
77
- self.outcomes: list[ToolOutcome] = []
78
- self.active_batch: list[ToolOutcome] = []
92
+ self.outcomes: list[ToolOutcome] = [
93
+ ToolOutcome(
94
+ deepcopy(call),
95
+ original_arguments=deepcopy(call.arguments),
96
+ )
97
+ for call in application_calls or []
98
+ ]
99
+ for outcome in self.outcomes:
100
+ outcome._set_origin("application", source)
101
+ self.active_batch: list[ToolOutcome] = list(self.outcomes)
79
102
  self._committed: set[int] = set()
80
103
  self._ended: set[int] = set()
81
104
  self._owned: set[asyncio.Future] = set()
@@ -95,11 +118,11 @@ class Run:
95
118
 
96
119
  @property
97
120
  def limits(self) -> RunLimits:
98
- return self.agent.limits
121
+ return self._limits if self.application_batch else self.agent.limits
99
122
 
100
123
  @property
101
124
  def execution_mode(self) -> str:
102
- return self.agent.execution_mode
125
+ return self._execution_mode if self.application_batch else self.agent.execution_mode
103
126
 
104
127
  @property
105
128
  def pending_calls(self) -> tuple[str, ...]:
@@ -199,7 +222,16 @@ class Run:
199
222
  await asyncio.gather(cancel_wait, return_exceptions=True)
200
223
 
201
224
  async def emit(self, kind: str, call_id: str | None = None, **data: Any) -> None:
202
- await self.agent._events.emit(kind, self.id, self.turn, data, call_id, self.await_owned)
225
+ if self.include_tool_origin and kind.startswith("tool_execution_"):
226
+ data.update(origin=self.origin, source=self.source)
227
+ await self.agent._events.emit(
228
+ kind,
229
+ self.id,
230
+ None if self.application_batch else self.turn,
231
+ data,
232
+ call_id,
233
+ self.await_owned,
234
+ )
203
235
 
204
236
  async def hook(self, name: str, *args: Any) -> Any:
205
237
  callback = getattr(self.hooks, name)
@@ -307,6 +339,8 @@ class Run:
307
339
  result = outcome.result
308
340
  outcome._settled = True
309
341
  self._committed.add(id(outcome))
342
+ if self.application_batch:
343
+ return # One custom batch record is appended after all accepted calls settle.
310
344
  message = ToolResultMessage(
311
345
  outcome.call.id,
312
346
  outcome.call.name,
@@ -330,6 +364,37 @@ class Run:
330
364
  execution_status=outcome.execution_status,
331
365
  )
332
366
 
367
+ async def append_application_batch(self) -> None:
368
+ calls = []
369
+ for outcome in self.outcomes:
370
+ result = outcome.result
371
+ assert result is not None # commit_outcome fills unresolved calls during cleanup.
372
+ calls.append(
373
+ {
374
+ "id": outcome.call.id,
375
+ "name": outcome.call.name,
376
+ "arguments": deepcopy(outcome.original_arguments),
377
+ "prepared_arguments": deepcopy(outcome.prepared_arguments),
378
+ "origin": outcome.origin,
379
+ "source": outcome.source,
380
+ "execution_status": outcome.execution_status,
381
+ "is_error": result.is_error,
382
+ "error_code": result.error_code,
383
+ "content": [asdict(block) for block in result.content],
384
+ }
385
+ )
386
+ await self.append(
387
+ CustomMessage(
388
+ "application_tool_batch",
389
+ {
390
+ "run_id": self.id,
391
+ "origin": self.origin,
392
+ "source": self.source,
393
+ "calls": calls,
394
+ },
395
+ )
396
+ )
397
+
333
398
  async def drive(self, pending: list[Message]) -> RunResult:
334
399
  agent = self.agent
335
400
  self.driver_started = True
@@ -339,7 +404,11 @@ class Run:
339
404
  # or aborted assistant message, followed by turn_end.
340
405
  failure: AssistantMessage | None = None
341
406
  try:
342
- status, reason = await run_loop(self, pending)
407
+ status, reason = (
408
+ await run_application_batch(self)
409
+ if self.application_batch
410
+ else await run_loop(self, pending)
411
+ )
343
412
  except asyncio.CancelledError:
344
413
  status, reason, failure = self.cancelled_outcome()
345
414
  except Exception as exc:
@@ -366,6 +435,16 @@ class Run:
366
435
  except Exception as exc:
367
436
  status = "failed"
368
437
  errors.append(f"{type(exc).__name__}: {exc}")
438
+ if self.application_batch:
439
+ failure = None # No model turn occurred, including on abort or failure.
440
+ try:
441
+ await self.append_application_batch()
442
+ except asyncio.CancelledError:
443
+ status = "cancelled" if status != "limit_reached" else status
444
+ errors.append("Batch message delivery cancelled")
445
+ except Exception as exc:
446
+ status = "failed"
447
+ errors.append(f"{type(exc).__name__}: {exc}")
369
448
  tail = agent._messages[-1] if len(agent._messages) > self.start else None
370
449
  if failure is not None and not (
371
450
  isinstance(tail, AssistantMessage) and tail.stop_reason in {"error", "aborted"}
@@ -420,9 +499,10 @@ class Run:
420
499
  self.fail(errors[-1])
421
500
  await self.cleanup()
422
501
  # Explicit config updates persist even if no further model request occurred.
423
- for update in agent._updates:
424
- merge(agent._defaults, update)
425
- agent._updates.clear()
502
+ if not self.application_batch:
503
+ for update in agent._updates:
504
+ merge(agent._defaults, update)
505
+ agent._updates.clear()
426
506
  usage: dict[str, Any] = {}
427
507
  for m in agent._messages[self.start :]:
428
508
  if isinstance(m, AssistantMessage):
pi_python/tools.py CHANGED
@@ -348,8 +348,45 @@ def apply_result_update(result: ToolResult, update: ToolResultUpdate) -> None:
348
348
  result.terminate = update.terminate
349
349
 
350
350
 
351
+ @dataclass(frozen=True)
352
+ class ToolInvocation:
353
+ """An application-requested call; the Agent assigns its ID."""
354
+
355
+ name: str
356
+ arguments: dict[str, Any]
357
+
358
+ def __post_init__(self) -> None:
359
+ if not isinstance(self.name, str) or not self.name:
360
+ raise ConfigurationError("ToolInvocation requires a nonempty tool name")
361
+ validate_json(self.arguments)
362
+ if not isinstance(self.arguments, dict):
363
+ raise ConfigurationError("ToolInvocation arguments must be an object")
364
+
365
+
366
+ class _ToolOrigin:
367
+ """Read-only provenance without changing legacy dataclass fields/asdict output.
368
+
369
+ Model calls need no per-instance storage. Application calls attach provenance
370
+ explicitly; deepcopy preserves it even though dataclasses.asdict excludes it.
371
+ """
372
+
373
+ _origin = "model"
374
+ _source: str | None = None
375
+
376
+ @property
377
+ def origin(self) -> str:
378
+ return self._origin
379
+
380
+ @property
381
+ def source(self) -> str | None:
382
+ return self._source
383
+
384
+ def _set_origin(self, origin: str, source: str | None) -> None:
385
+ self._origin, self._source = origin, source
386
+
387
+
351
388
  @dataclass
352
- class ToolContext:
389
+ class ToolContext(_ToolOrigin):
353
390
  run_id: str
354
391
  call_id: str
355
392
  cancel: CancelToken
@@ -449,7 +486,7 @@ class Tool:
449
486
 
450
487
 
451
488
  @dataclass
452
- class ToolOutcome:
489
+ class ToolOutcome(_ToolOrigin):
453
490
  call: ToolCall
454
491
  execution_status: str = "not_started"
455
492
  original_arguments: dict[str, Any] = field(default_factory=dict)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pi-python-core
3
- Version: 0.10.1
3
+ Version: 0.11.0
4
4
  Summary: An embeddable asyncio agent core with opt-in Claude and OpenAI providers
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -111,7 +111,7 @@ Apart from `provider_chat`, which needs real credentials, every example runs off
111
111
 
112
112
  The run loop, event order, hooks, queues, and the handling of errors and cancellation all match Pi, and this is checked differentially: the same inputs go to the pinned upstream code and to this library, and the model requests, tool calls, events and final transcript are compared item by item. All cases currently agree: 25 for the core loop, 50 for model connectors, 2 for multi-turn WebSocket, and 44 error-classification samples. The plugin rules for skills, prompt templates and frontmatter are compared the same way against Pi's coding-agent code.
113
113
 
114
- A few differences are deliberate, such as strict tool-argument validation without type coercion, and returning copies of state to callers. Others are additions for Python users, such as `@tool`, blocking calls, and calling `continue_run()` directly after a failure. Each one is recorded in the [coverage map](compat/COVERAGE.md) (Chinese). Features Pi keeps in its application layer (terminal UI, session file format, context compaction) are not in this core; compaction can be built with hooks, and an example shows the full approach. Plugins are the one piece taken from that layer: an optional module that reads Pi's package format and builds an ordinary Agent from it. This project uses its own version numbers and is not an official Pi release.
114
+ A few differences are deliberate, such as strict tool-argument validation without type coercion, and returning copies of state to callers. Others are additions for Python users, such as `@tool`, blocking calls, and calling `continue_run()` directly after a failure. Each one is recorded in the [coverage map](compat/COVERAGE.md) (Chinese). Features Pi keeps in its application layer (terminal UI, session file format, context compaction) are not in this core; compaction can be built with hooks, and an example shows the full approach. The Agent also supplies cache-only session IDs for Codex, a responsibility upstream assigns to its coding-agent layer; see [cache sessions and the opt-out](docs/PROVIDERS.md#automatic-codex-cache-sessions). Plugins are another piece taken from that layer: an optional module that reads Pi's package format and builds an ordinary Agent from it. This project uses its own version numbers and is not an official Pi release.
115
115
 
116
116
  ## Documentation
117
117
 
@@ -1,34 +1,34 @@
1
- pi_python/__init__.py,sha256=S_XoKUqwBbptuM9TSffhcy0NSedNhq0oCJ_huFcDvR0,3916
1
+ pi_python/__init__.py,sha256=-32DW8gNgcvPYNQJZUago0hrkgx_v4VZnOsr4BmG46M,3958
2
2
  pi_python/_mcp_host.py,sha256=CXio-tiEeUCUfdl65tatwSrzIOYdgecdS00sO6tNy5k,8630
3
3
  pi_python/_mcp_interaction.py,sha256=jPL-C8wLjBDV9tOdvGVFfXOl8-1FQIDJLq-MojlzXaM,43851
4
4
  pi_python/_mcp_process.py,sha256=4szi5gMEP_0GUGOcV-oLPUuVQAUSb0ToAaiM979zHME,6324
5
5
  pi_python/_mcp_sampling.py,sha256=BcWCrPgjsRC-GNx65fIB-JPq2WzWnoBK2w9iTnyPUww,12148
6
- pi_python/_version.py,sha256=v7Gyp89umFzDtY45tTjCdXqZnQ2RN01AibdYNxEvxYo,23
7
- pi_python/agent.py,sha256=I4P4waCdkcURDd022H4XgBbHNhpa7LrI5Iwb5ncxhGU,16021
6
+ pi_python/_version.py,sha256=raMu9XA9JEjvdoTmFqcOw7qhJX24rYDP7XmS59TAO-Q,23
7
+ pi_python/agent.py,sha256=5yd5D_hDGwp74dtvH1SCQUQdLFpc1nxTqAEKBbleSDQ,19797
8
8
  pi_python/cancellation.py,sha256=DoQVdX7DcQdVLkl4OvhcpMhKZJEW0Kscl2-xxF6i6Y0,608
9
9
  pi_python/errors.py,sha256=65lT0nYfzqxRfUlFG-Y9vgYxfDHEuFiBfgqmFnX9_O8,675
10
10
  pi_python/estimate.py,sha256=dQQJ9UEGp57F3wO_CcU2oubgPe841ttgpUseK3Fu_0w,5048
11
- pi_python/events.py,sha256=t05VE-km2ZcsdIXCviVzNFQCob4xBghDfNeBRWpLrCA,4594
12
- pi_python/features.py,sha256=3jYcbDnQloPQaQn_qro952tehZYqXqbgsejsdGaiOqY,1642
11
+ pi_python/events.py,sha256=HFYaPB9-yjOvmuM2Ly7SgfWDMJJfX9e1Q7ethqOHSB8,4626
12
+ pi_python/features.py,sha256=Q7X1ke7FcGCyb8XURw4iDa2-wfbaWQQCdbR4-Zh5fsk,1720
13
13
  pi_python/function_tools.py,sha256=DO8fZD-n5i62w22zLIjblCOTwnG8N-HIoSUo4yOpRM4,18862
14
14
  pi_python/hooks.py,sha256=jm6ep78NjdHxIPZvcOXTZIPxkfBMkffVTNzLvnHS9mM,1321
15
15
  pi_python/limits.py,sha256=K1aP1cFH4fVhtWIXqpWMmpkB9AI0wK-bz3Xd2hxnOs4,1157
16
- pi_python/loop.py,sha256=b0X_hNYXPQZcAjsyw3RIGdYLoZQHhKxm3nBtiznejak,17839
16
+ pi_python/loop.py,sha256=Q67rzG0eERY3Y_NMDdCIbKZiadgdcF2sSB83qni92ms,19263
17
17
  pi_python/lowlevel.py,sha256=56wtwqD0Ur1AGAXGa7zclb1IpmRPFpSEss_nspkAdrI,5514
18
18
  pi_python/mcp.py,sha256=f7VOiOeBwJ48SrowJWmqtH3d-iavrv2GZo2KH45qeTA,16713
19
- pi_python/messages.py,sha256=w5e-lc9DLW_4vYZ73Wj32RXfNYzDHdp4HCYsF5qkfHY,15665
19
+ pi_python/messages.py,sha256=jUoQxR-UZbIQYWu257dGiCx8K5z_RV89fJpHgv4t-Ww,18068
20
20
  pi_python/models.py,sha256=IejtaWvbURM9vv_2kkoDzfto2TR0OSLjuQeOh3IJZ1k,6094
21
- pi_python/provider.py,sha256=k_VfJu5WOQla_LkglTDAq5s-77ejCj4YRTyhJ8onZBo,4259
21
+ pi_python/provider.py,sha256=WH1-8YQKuV0PrBTLdNJKN9oXYnnOQiCxt4kD2BL-ebw,5047
22
22
  pi_python/proxy.py,sha256=kpEUoE1oDkJW7VVaV72slN3uJUFgYR4Q6jtYOEe8LmQ,12159
23
23
  pi_python/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
24
24
  pi_python/queues.py,sha256=nQ5xRSA3MMOHMs_ZJnG9sT3-d_yKn72oSnxXqiKPrcI,3032
25
25
  pi_python/recovery.py,sha256=z5ht-6FoKtC5BPEaSgXiRE2KVf94mZDiKMSxV0mkZ-4,8138
26
- pi_python/run.py,sha256=J53DLttQNcIUwvGryvEjVuoyCBxdmAsqetQvxmliej8,18640
26
+ pi_python/run.py,sha256=S-mJ5xHVzVXdt8aBicRr4jU1-VZW1-QDi4Cru2hPgmE,21831
27
27
  pi_python/stream.py,sha256=R3v-qjCHFQJhHQk83qATz7lC3yr8paJguaHwk8RoHow,10729
28
28
  pi_python/sync.py,sha256=uTTX07hxe6TOCgwtfBxSJh2oJFDa2UklNmlCJ9g5-ss,5261
29
29
  pi_python/tasks.py,sha256=UNPhb_HSTAaALcstV4X9T4q8JZTyMf90LjRR5bFw26M,4738
30
30
  pi_python/testing.py,sha256=XfTSOkEv0CenqrBILI_T205jOhyitW53YQZ9yiMXir4,1046
31
- pi_python/tools.py,sha256=zBrYVoPOgrVHqvrL5sIhMXCf501QjyuDNdmR-tV6Ads,23274
31
+ pi_python/tools.py,sha256=507d9JXMdn1fsoS1Y1TZIauEf4d570vDeRMfavb9itY,24417
32
32
  pi_python/transcript.py,sha256=g6Tg8eiyMCAK43iLpcBv220MHBq8kO7Ty-6OYLCzw2A,6685
33
33
  pi_python/data/models.json,sha256=oa_LGv7vaRrS-l7_Z7iu8f7WTyoJJLPhUQyptUz7OmE,77549
34
34
  pi_python/plugins/__init__.py,sha256=FeYHYxgaOjKuq3QBBv1ezOyfORi5Fl2SdnImN_O0IrE,56156
@@ -41,10 +41,10 @@ pi_python/providers/anthropic.py,sha256=PCH9h9WHNXbz7Pv01NM1d8NtHANthLRaU1mZUVUc
41
41
  pi_python/providers/common.py,sha256=z8dB2pIc-dRscPAppnA3ZwF_wJygunQb9pI9Wl4CvaA,8051
42
42
  pi_python/providers/completions.py,sha256=vcqoLfJ-9quMREEEAJcHYs3Oq-A0Sz8Q1NV1QLd3OJA,51362
43
43
  pi_python/providers/oauth.py,sha256=ZY8j7P-I-R6hZ8sInkGao957KWNGsqfvMF0F-YzlSgU,22786
44
- pi_python/providers/openai.py,sha256=p0WHDnojAYb7mUX8EbComwEU_XskcYoMhMIR07QlSfc,30196
44
+ pi_python/providers/openai.py,sha256=c2xzfRd0RgYxbtgl-ruA0yLySq5W63mmwyu_HFQm2g0,30931
45
45
  pi_python/providers/transport.py,sha256=B1mGKlVEqYGVK1MlcA9opwRhnuUYGuaYuTslNC3O8xs,26772
46
- pi_python_core-0.10.1.dist-info/METADATA,sha256=AncCL6Sz-4xE1Kx9_iHB2TmHcOtq8jJjBFHxAGExpxs,8939
47
- pi_python_core-0.10.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
48
- pi_python_core-0.10.1.dist-info/licenses/LICENSE,sha256=BFf1vOw7OyEWBd-10aSQQv1jjzaGpBD-CZwkolrxPEg,1069
49
- pi_python_core-0.10.1.dist-info/licenses/NOTICE,sha256=fJna16fm1ECHN7zVZhtyNmjLw8KGxGp8mL9blISF1K0,633
50
- pi_python_core-0.10.1.dist-info/RECORD,,
46
+ pi_python_core-0.11.0.dist-info/METADATA,sha256=m36iv6YTPLV0GsXN29gp-rIwgRQSnO8m-uAtgvsovYs,9146
47
+ pi_python_core-0.11.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
48
+ pi_python_core-0.11.0.dist-info/licenses/LICENSE,sha256=BFf1vOw7OyEWBd-10aSQQv1jjzaGpBD-CZwkolrxPEg,1069
49
+ pi_python_core-0.11.0.dist-info/licenses/NOTICE,sha256=fJna16fm1ECHN7zVZhtyNmjLw8KGxGp8mL9blISF1K0,633
50
+ pi_python_core-0.11.0.dist-info/RECORD,,