zenagent-py 0.1.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.
- zenagent/__init__.py +1 -0
- zenagent/agent/__init__.py +24 -0
- zenagent/agent/agent.py +264 -0
- zenagent/agent/loop.py +534 -0
- zenagent/agent/session.py +185 -0
- zenagent/agent/validate.py +85 -0
- zenagent/catalog.yaml +119 -0
- zenagent/cli/__init__.py +4 -0
- zenagent/cli/compact.py +165 -0
- zenagent/cli/completion.py +116 -0
- zenagent/cli/doctor.py +97 -0
- zenagent/cli/exportcmd.py +37 -0
- zenagent/cli/images.py +61 -0
- zenagent/cli/keybindings.py +87 -0
- zenagent/cli/main.py +666 -0
- zenagent/cli/permissions.py +183 -0
- zenagent/cli/render.py +225 -0
- zenagent/cli/repl.py +777 -0
- zenagent/cli/rpc.py +224 -0
- zenagent/cli/theme.py +97 -0
- zenagent/cli/usagecmd.py +24 -0
- zenagent/config.py +309 -0
- zenagent/context_files.py +88 -0
- zenagent/crash.py +96 -0
- zenagent/evals_runner.py +83 -0
- zenagent/events.py +107 -0
- zenagent/export.py +180 -0
- zenagent/extensions.py +130 -0
- zenagent/frontmatter.py +39 -0
- zenagent/mcp/__init__.py +29 -0
- zenagent/mcp/client.py +421 -0
- zenagent/mcp/config.py +102 -0
- zenagent/mcp/manager.py +223 -0
- zenagent/providers/__init__.py +2 -0
- zenagent/providers/anthropic_messages.py +548 -0
- zenagent/providers/catalog.py +400 -0
- zenagent/providers/compat.py +88 -0
- zenagent/providers/fixtures/anthropic-badjson.txt +17 -0
- zenagent/providers/fixtures/anthropic-text.txt +20 -0
- zenagent/providers/fixtures/anthropic-thinking-toolcall.txt +32 -0
- zenagent/providers/fixtures/badjson-toolcall.txt +10 -0
- zenagent/providers/fixtures/deepseek-chat-live-text.request.json +19 -0
- zenagent/providers/fixtures/deepseek-chat-live-text.txt +58 -0
- zenagent/providers/fixtures/deepseek-chat-live-toolcall.request.json +39 -0
- zenagent/providers/fixtures/deepseek-chat-live-toolcall.txt +22 -0
- zenagent/providers/fixtures/deepseek-reasoner-live-thinking.request.json +22 -0
- zenagent/providers/fixtures/deepseek-reasoner-live-thinking.txt +360 -0
- zenagent/providers/fixtures/deepseek-reasoner.txt +12 -0
- zenagent/providers/fixtures/google-text.txt +4 -0
- zenagent/providers/fixtures/google-toolcall.txt +2 -0
- zenagent/providers/fixtures/multi-tool-parallel.txt +12 -0
- zenagent/providers/fixtures/openai-responses-text.txt +12 -0
- zenagent/providers/fixtures/openai-responses-toolcall.txt +18 -0
- zenagent/providers/fixtures/zai-glm-5.3-text+thinking.txt +14 -0
- zenagent/providers/fixtures/zai-glm-5.3-toolcall.txt +12 -0
- zenagent/providers/google_generative_ai.py +244 -0
- zenagent/providers/openai_completions.py +634 -0
- zenagent/providers/openai_responses.py +288 -0
- zenagent/providers/registry.py +148 -0
- zenagent/providers/transport.py +257 -0
- zenagent/retry.py +112 -0
- zenagent/skills.py +170 -0
- zenagent/telemetry.py +89 -0
- zenagent/templates.py +95 -0
- zenagent/tools/__init__.py +22 -0
- zenagent/tools/bash.py +225 -0
- zenagent/tools/fs.py +280 -0
- zenagent/tools/search.py +200 -0
- zenagent/trace.py +94 -0
- zenagent/types.py +346 -0
- zenagent/usage.py +142 -0
- zenagent_py-0.1.0.dist-info/METADATA +253 -0
- zenagent_py-0.1.0.dist-info/RECORD +75 -0
- zenagent_py-0.1.0.dist-info/WHEEL +4 -0
- zenagent_py-0.1.0.dist-info/entry_points.txt +4 -0
zenagent/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Agent package: the loop, its config/state/tool surface, and the Agent shell.
|
|
2
|
+
|
|
3
|
+
``zenagent.agent.loop`` is the pure core; ``zenagent.agent.agent.Agent`` is the
|
|
4
|
+
stateful wrapper harnesses drive. Tool packages (later tasks) import
|
|
5
|
+
``AgentTool``/``ToolResultAgent`` from here to build instances.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from zenagent.agent.agent import Agent
|
|
9
|
+
from zenagent.agent.loop import (
|
|
10
|
+
AgentConfig,
|
|
11
|
+
AgentState,
|
|
12
|
+
AgentTool,
|
|
13
|
+
ToolResultAgent,
|
|
14
|
+
agent_loop,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Agent",
|
|
19
|
+
"AgentConfig",
|
|
20
|
+
"AgentState",
|
|
21
|
+
"AgentTool",
|
|
22
|
+
"ToolResultAgent",
|
|
23
|
+
"agent_loop",
|
|
24
|
+
]
|
zenagent/agent/agent.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Stateful wrapper around :func:`~zenagent.agent.loop.agent_loop`.
|
|
2
|
+
|
|
3
|
+
The loop is a pure function: it takes a transcript, streams one run, and
|
|
4
|
+
appends messages to the state it was handed. Harnesses (headless CLI, REPL)
|
|
5
|
+
need something with a longer life than one run: they hold the transcript, want
|
|
6
|
+
to observe events, inject input mid-run, cancel, and know when things are idle.
|
|
7
|
+
|
|
8
|
+
``Agent`` is that shell — it mirrors what pi-agent-core's ``Agent`` class does
|
|
9
|
+
on top of its ``agentLoop()`` function:
|
|
10
|
+
|
|
11
|
+
* owns the transcript (``messages``) and the run configuration;
|
|
12
|
+
* ``subscribe`` for ``AgentEvent`` consumers, delivered serially;
|
|
13
|
+
* ``steer`` queues user input that the loop drains after a tool round;
|
|
14
|
+
* ``abort`` cancels the current run cooperatively (the run still ends with an
|
|
15
|
+
``aborted`` terminal state, never by raising);
|
|
16
|
+
* ``wait_for_idle`` resolves once the run finished *and* its ``agent_end``
|
|
17
|
+
listeners settled;
|
|
18
|
+
* ``prompt`` / ``continue_`` / ``reset`` / ``replace_messages`` for driving it.
|
|
19
|
+
|
|
20
|
+
Only one run may be active at a time.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import inspect
|
|
25
|
+
import sys
|
|
26
|
+
import time
|
|
27
|
+
from collections.abc import Awaitable, Callable
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
|
|
30
|
+
from zenagent.agent.loop import (
|
|
31
|
+
AgentConfig,
|
|
32
|
+
AgentState,
|
|
33
|
+
AgentTool,
|
|
34
|
+
agent_loop,
|
|
35
|
+
)
|
|
36
|
+
from zenagent.events import AbortToken, AgentEvent
|
|
37
|
+
from zenagent.providers.catalog import Model
|
|
38
|
+
from zenagent.providers.registry import StreamFn
|
|
39
|
+
from zenagent.types import (
|
|
40
|
+
AssistantMessage,
|
|
41
|
+
ImageContent,
|
|
42
|
+
Message,
|
|
43
|
+
TextContent,
|
|
44
|
+
UserMessage,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
__all__ = ["Agent"]
|
|
48
|
+
|
|
49
|
+
#: Listener signature: receives every event of a run, may be sync or async.
|
|
50
|
+
AgentListener = Callable[[AgentEvent], Awaitable[None] | None]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class _Run:
|
|
55
|
+
"""Bookkeeping for one in-flight run."""
|
|
56
|
+
|
|
57
|
+
abort: AbortToken
|
|
58
|
+
#: Resolved when the run (including its listeners) has settled.
|
|
59
|
+
done: asyncio.Event = field(default_factory=asyncio.Event)
|
|
60
|
+
error_message: str | None = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Agent:
|
|
64
|
+
"""Transcript + configuration, driven one run at a time.
|
|
65
|
+
|
|
66
|
+
Run-visible attributes (``model``, ``thinking``, ``tools``) are read when a
|
|
67
|
+
run starts, so changing them takes effect on the next turn — that is how
|
|
68
|
+
the REPL's ``/model`` and ``/thinking`` commands work.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
model: Model,
|
|
74
|
+
system_prompt: str,
|
|
75
|
+
tools: list[AgentTool] | None = None,
|
|
76
|
+
api_key: str = "",
|
|
77
|
+
*,
|
|
78
|
+
messages: list[Message] | None = None,
|
|
79
|
+
thinking: str | None = None,
|
|
80
|
+
session_id: str | None = None,
|
|
81
|
+
stream_fn: StreamFn | None = None,
|
|
82
|
+
before_tool_call: Callable | None = None,
|
|
83
|
+
after_tool_call: Callable | None = None,
|
|
84
|
+
transform_context: Callable | None = None,
|
|
85
|
+
max_turns: int | None = None,
|
|
86
|
+
max_cost: float | None = None,
|
|
87
|
+
retries: int = 0,
|
|
88
|
+
) -> None:
|
|
89
|
+
self.model = model
|
|
90
|
+
self.system_prompt = system_prompt
|
|
91
|
+
self.tools: list[AgentTool] = list(tools or [])
|
|
92
|
+
self.api_key = api_key
|
|
93
|
+
self.thinking = thinking
|
|
94
|
+
self.session_id = session_id
|
|
95
|
+
self.stream_fn = stream_fn
|
|
96
|
+
self.before_tool_call = before_tool_call
|
|
97
|
+
self.after_tool_call = after_tool_call
|
|
98
|
+
self.transform_context = transform_context
|
|
99
|
+
self.max_turns = max_turns
|
|
100
|
+
self.max_cost = max_cost
|
|
101
|
+
self.retries = retries
|
|
102
|
+
|
|
103
|
+
self._state = AgentState(messages=list(messages or []))
|
|
104
|
+
self._listeners: list[AgentListener] = []
|
|
105
|
+
self._steering: list[str] = []
|
|
106
|
+
self._run: _Run | None = None
|
|
107
|
+
self._error_message: str | None = None
|
|
108
|
+
|
|
109
|
+
# -- observability ------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def messages(self) -> list[Message]:
|
|
113
|
+
"""A shallow copy of the transcript."""
|
|
114
|
+
return list(self._state.messages)
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def is_streaming(self) -> bool:
|
|
118
|
+
return self._run is not None
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def is_idle(self) -> bool:
|
|
122
|
+
return self._run is None
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def termination(self) -> str | None:
|
|
126
|
+
"""Why the most recent run ended (``"stop"``, ``"max_cost"``, ...)."""
|
|
127
|
+
return self._state.termination
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def error_message(self) -> str | None:
|
|
131
|
+
"""Error/abort detail from the most recent run, if any."""
|
|
132
|
+
return self._error_message
|
|
133
|
+
|
|
134
|
+
def subscribe(self, listener: AgentListener) -> Callable[[], None]:
|
|
135
|
+
"""Register an event listener; returns an unsubscribe callable."""
|
|
136
|
+
self._listeners.append(listener)
|
|
137
|
+
|
|
138
|
+
def unsubscribe() -> None:
|
|
139
|
+
if listener in self._listeners:
|
|
140
|
+
self._listeners.remove(listener)
|
|
141
|
+
|
|
142
|
+
return unsubscribe
|
|
143
|
+
|
|
144
|
+
# -- queueing -----------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def steer(self, text: str) -> None:
|
|
147
|
+
"""Queue input to be injected after the current tool round."""
|
|
148
|
+
self._steering.append(text)
|
|
149
|
+
|
|
150
|
+
# -- control ------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
def abort(self) -> None:
|
|
153
|
+
"""Cooperatively cancel the current run (no-op when idle)."""
|
|
154
|
+
if self._run is not None:
|
|
155
|
+
self._run.abort.abort()
|
|
156
|
+
|
|
157
|
+
async def wait_for_idle(self) -> None:
|
|
158
|
+
"""Wait until no run is active and its listeners have settled."""
|
|
159
|
+
run = self._run
|
|
160
|
+
if run is not None:
|
|
161
|
+
await run.done.wait()
|
|
162
|
+
|
|
163
|
+
async def prompt(
|
|
164
|
+
self, text: str, images: list[ImageContent] | None = None
|
|
165
|
+
) -> None:
|
|
166
|
+
"""Start a run with a new user message and return when it settles."""
|
|
167
|
+
if images:
|
|
168
|
+
await self._drive([*images, TextContent(text=text)])
|
|
169
|
+
else:
|
|
170
|
+
await self._drive(text)
|
|
171
|
+
|
|
172
|
+
async def continue_(self) -> None:
|
|
173
|
+
"""Start a run without adding a message (retry/replay), e.g. after a resume."""
|
|
174
|
+
if not self._state.messages:
|
|
175
|
+
raise ValueError("cannot continue: the transcript is empty")
|
|
176
|
+
await self._drive(None)
|
|
177
|
+
|
|
178
|
+
def replace_messages(self, messages: list[Message]) -> None:
|
|
179
|
+
"""Replace the whole transcript (used by ``--resume`` and ``/compact``)."""
|
|
180
|
+
if self._run is not None:
|
|
181
|
+
raise RuntimeError("cannot replace messages while a run is active")
|
|
182
|
+
self._state.messages = list(messages)
|
|
183
|
+
|
|
184
|
+
def reset(self) -> None:
|
|
185
|
+
"""Clear the transcript and pending steering input (config is kept)."""
|
|
186
|
+
if self._run is not None:
|
|
187
|
+
raise RuntimeError("cannot reset while a run is active")
|
|
188
|
+
self._state.messages = []
|
|
189
|
+
self._steering.clear()
|
|
190
|
+
self._error_message = None
|
|
191
|
+
|
|
192
|
+
# -- internals ----------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
def _config(self) -> AgentConfig:
|
|
195
|
+
return AgentConfig(
|
|
196
|
+
model=self.model,
|
|
197
|
+
system_prompt=self.system_prompt,
|
|
198
|
+
tools=list(self.tools),
|
|
199
|
+
thinking=self.thinking,
|
|
200
|
+
api_key=self.api_key,
|
|
201
|
+
session_id=self.session_id,
|
|
202
|
+
before_tool_call=self.before_tool_call,
|
|
203
|
+
after_tool_call=self.after_tool_call,
|
|
204
|
+
get_steering_messages=self._drain_steering,
|
|
205
|
+
transform_context=self.transform_context,
|
|
206
|
+
stream_fn=self.stream_fn,
|
|
207
|
+
max_turns=self.max_turns,
|
|
208
|
+
max_cost=self.max_cost,
|
|
209
|
+
retries=self.retries,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
async def _drain_steering(self) -> list[Message]:
|
|
213
|
+
"""Aggregate queued lines into one user message and clear the queue."""
|
|
214
|
+
if not self._steering:
|
|
215
|
+
return []
|
|
216
|
+
lines = self._steering
|
|
217
|
+
self._steering = []
|
|
218
|
+
return [UserMessage(content="\n".join(lines), timestamp=int(time.time()))]
|
|
219
|
+
|
|
220
|
+
async def _dispatch(self, event: AgentEvent) -> None:
|
|
221
|
+
"""Deliver one event to every listener, serially, isolating failures."""
|
|
222
|
+
for listener in list(self._listeners):
|
|
223
|
+
try:
|
|
224
|
+
result = listener(event)
|
|
225
|
+
if inspect.isawaitable(result):
|
|
226
|
+
await result
|
|
227
|
+
except Exception as exc: # noqa: BLE001 - a bad listener must not kill the run
|
|
228
|
+
print(
|
|
229
|
+
f"[zenagent] agent listener failed on {event.type}: "
|
|
230
|
+
f"{type(exc).__name__}: {exc}",
|
|
231
|
+
file=sys.stderr,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
async def _drive(
|
|
235
|
+
self, prompt: str | list[TextContent | ImageContent] | None
|
|
236
|
+
) -> None:
|
|
237
|
+
if self._run is not None:
|
|
238
|
+
raise RuntimeError("Agent is already running; await wait_for_idle() first")
|
|
239
|
+
run = _Run(abort=AbortToken())
|
|
240
|
+
self._run = run
|
|
241
|
+
self._error_message = None
|
|
242
|
+
try:
|
|
243
|
+
async for event in agent_loop(prompt, self._state, self._config(), run.abort):
|
|
244
|
+
await self._dispatch(event)
|
|
245
|
+
if event.type == "agent_end":
|
|
246
|
+
run.error_message = _terminal_error(self._state.messages)
|
|
247
|
+
except BaseException:
|
|
248
|
+
# Task cancellation propagates, but the run must still become idle.
|
|
249
|
+
self._error_message = run.error_message or "run cancelled"
|
|
250
|
+
raise
|
|
251
|
+
finally:
|
|
252
|
+
self._error_message = run.error_message
|
|
253
|
+
self._run = None
|
|
254
|
+
run.done.set()
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _terminal_error(messages: list[Message]) -> str | None:
|
|
258
|
+
"""Error/abort text from the run's last assistant message, if it failed."""
|
|
259
|
+
for message in reversed(messages):
|
|
260
|
+
if isinstance(message, AssistantMessage):
|
|
261
|
+
if message.stop_reason in ("error", "aborted"):
|
|
262
|
+
return message.error_message or message.stop_reason
|
|
263
|
+
return None
|
|
264
|
+
return None
|