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.
Files changed (75) hide show
  1. zenagent/__init__.py +1 -0
  2. zenagent/agent/__init__.py +24 -0
  3. zenagent/agent/agent.py +264 -0
  4. zenagent/agent/loop.py +534 -0
  5. zenagent/agent/session.py +185 -0
  6. zenagent/agent/validate.py +85 -0
  7. zenagent/catalog.yaml +119 -0
  8. zenagent/cli/__init__.py +4 -0
  9. zenagent/cli/compact.py +165 -0
  10. zenagent/cli/completion.py +116 -0
  11. zenagent/cli/doctor.py +97 -0
  12. zenagent/cli/exportcmd.py +37 -0
  13. zenagent/cli/images.py +61 -0
  14. zenagent/cli/keybindings.py +87 -0
  15. zenagent/cli/main.py +666 -0
  16. zenagent/cli/permissions.py +183 -0
  17. zenagent/cli/render.py +225 -0
  18. zenagent/cli/repl.py +777 -0
  19. zenagent/cli/rpc.py +224 -0
  20. zenagent/cli/theme.py +97 -0
  21. zenagent/cli/usagecmd.py +24 -0
  22. zenagent/config.py +309 -0
  23. zenagent/context_files.py +88 -0
  24. zenagent/crash.py +96 -0
  25. zenagent/evals_runner.py +83 -0
  26. zenagent/events.py +107 -0
  27. zenagent/export.py +180 -0
  28. zenagent/extensions.py +130 -0
  29. zenagent/frontmatter.py +39 -0
  30. zenagent/mcp/__init__.py +29 -0
  31. zenagent/mcp/client.py +421 -0
  32. zenagent/mcp/config.py +102 -0
  33. zenagent/mcp/manager.py +223 -0
  34. zenagent/providers/__init__.py +2 -0
  35. zenagent/providers/anthropic_messages.py +548 -0
  36. zenagent/providers/catalog.py +400 -0
  37. zenagent/providers/compat.py +88 -0
  38. zenagent/providers/fixtures/anthropic-badjson.txt +17 -0
  39. zenagent/providers/fixtures/anthropic-text.txt +20 -0
  40. zenagent/providers/fixtures/anthropic-thinking-toolcall.txt +32 -0
  41. zenagent/providers/fixtures/badjson-toolcall.txt +10 -0
  42. zenagent/providers/fixtures/deepseek-chat-live-text.request.json +19 -0
  43. zenagent/providers/fixtures/deepseek-chat-live-text.txt +58 -0
  44. zenagent/providers/fixtures/deepseek-chat-live-toolcall.request.json +39 -0
  45. zenagent/providers/fixtures/deepseek-chat-live-toolcall.txt +22 -0
  46. zenagent/providers/fixtures/deepseek-reasoner-live-thinking.request.json +22 -0
  47. zenagent/providers/fixtures/deepseek-reasoner-live-thinking.txt +360 -0
  48. zenagent/providers/fixtures/deepseek-reasoner.txt +12 -0
  49. zenagent/providers/fixtures/google-text.txt +4 -0
  50. zenagent/providers/fixtures/google-toolcall.txt +2 -0
  51. zenagent/providers/fixtures/multi-tool-parallel.txt +12 -0
  52. zenagent/providers/fixtures/openai-responses-text.txt +12 -0
  53. zenagent/providers/fixtures/openai-responses-toolcall.txt +18 -0
  54. zenagent/providers/fixtures/zai-glm-5.3-text+thinking.txt +14 -0
  55. zenagent/providers/fixtures/zai-glm-5.3-toolcall.txt +12 -0
  56. zenagent/providers/google_generative_ai.py +244 -0
  57. zenagent/providers/openai_completions.py +634 -0
  58. zenagent/providers/openai_responses.py +288 -0
  59. zenagent/providers/registry.py +148 -0
  60. zenagent/providers/transport.py +257 -0
  61. zenagent/retry.py +112 -0
  62. zenagent/skills.py +170 -0
  63. zenagent/telemetry.py +89 -0
  64. zenagent/templates.py +95 -0
  65. zenagent/tools/__init__.py +22 -0
  66. zenagent/tools/bash.py +225 -0
  67. zenagent/tools/fs.py +280 -0
  68. zenagent/tools/search.py +200 -0
  69. zenagent/trace.py +94 -0
  70. zenagent/types.py +346 -0
  71. zenagent/usage.py +142 -0
  72. zenagent_py-0.1.0.dist-info/METADATA +253 -0
  73. zenagent_py-0.1.0.dist-info/RECORD +75 -0
  74. zenagent_py-0.1.0.dist-info/WHEEL +4 -0
  75. 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
+ ]
@@ -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