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 +5 -0
- pi_python/_version.py +1 -1
- pi_python/agent.py +66 -6
- pi_python/core.py +538 -0
- pi_python/data/models.json +146 -189
- pi_python/estimate.py +3 -3
- pi_python/events.py +2 -2
- pi_python/features.py +5 -0
- pi_python/identity.py +101 -0
- pi_python/loop.py +47 -1
- pi_python/lowlevel.py +27 -13
- pi_python/messages.py +8 -2
- pi_python/models.py +6 -0
- pi_python/plugins/_resources.py +6 -2
- pi_python/provider.py +18 -0
- pi_python/providers/anthropic.py +85 -59
- pi_python/providers/common.py +73 -3
- pi_python/providers/completions.py +33 -22
- pi_python/providers/oauth.py +18 -8
- pi_python/providers/openai.py +125 -35
- pi_python/providers/transport.py +78 -17
- pi_python/queues.py +2 -0
- pi_python/recovery.py +20 -9
- pi_python/run.py +16 -0
- pi_python/stream.py +32 -2
- pi_python/tools.py +10 -3
- pi_python/transcript.py +18 -0
- {pi_python_core-0.10.2.dist-info → pi_python_core-0.12.0.dist-info}/METADATA +8 -5
- pi_python_core-0.12.0.dist-info/RECORD +52 -0
- {pi_python_core-0.10.2.dist-info → pi_python_core-0.12.0.dist-info}/licenses/NOTICE +5 -0
- pi_python_core-0.10.2.dist-info/RECORD +0 -50
- {pi_python_core-0.10.2.dist-info → pi_python_core-0.12.0.dist-info}/WHEEL +0 -0
- {pi_python_core-0.10.2.dist-info → pi_python_core-0.12.0.dist-info}/licenses/LICENSE +0 -0
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.
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
)
|