agentix-toolkit 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.
- agentix/__init__.py +123 -0
- agentix/agent.py +455 -0
- agentix/concurrency.py +79 -0
- agentix/confirm.py +30 -0
- agentix/context.py +114 -0
- agentix/control.py +28 -0
- agentix/errors.py +23 -0
- agentix/events.py +42 -0
- agentix/executors.py +99 -0
- agentix/guards/__init__.py +54 -0
- agentix/guards/base.py +123 -0
- agentix/guards/injection.py +66 -0
- agentix/guards/pii.py +84 -0
- agentix/guards/tiers.py +25 -0
- agentix/guards/trust.py +52 -0
- agentix/mcp.py +166 -0
- agentix/model.py +34 -0
- agentix/policy.py +59 -0
- agentix/pricing.py +33 -0
- agentix/providers/__init__.py +8 -0
- agentix/providers/anthropic.py +212 -0
- agentix/providers/mock.py +61 -0
- agentix/py.typed +0 -0
- agentix/serde.py +87 -0
- agentix/store.py +94 -0
- agentix/streaming.py +88 -0
- agentix/subagents.py +59 -0
- agentix/tools.py +261 -0
- agentix/types.py +89 -0
- agentix_toolkit-0.1.0.dist-info/METADATA +207 -0
- agentix_toolkit-0.1.0.dist-info/RECORD +33 -0
- agentix_toolkit-0.1.0.dist-info/WHEEL +4 -0
- agentix_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
agentix/__init__.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""agentix — a generic, batteries-included agent toolkit.
|
|
2
|
+
|
|
3
|
+
Configure the agent loop, tools, guards, and observability instead of
|
|
4
|
+
rewriting them for every project. The core is provider-agnostic and async-first.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .agent import Agent
|
|
10
|
+
from .concurrency import Limiter, bounded_gather
|
|
11
|
+
from .confirm import ConfirmFn, always_approve, always_deny, console_confirm
|
|
12
|
+
from .context import ContextStrategy, TrimRounds, TruncateToolOutputs
|
|
13
|
+
from .control import Interrupt
|
|
14
|
+
from .errors import AgentError, BudgetExceeded, GuardError, ToolError
|
|
15
|
+
from .events import AgentEvents
|
|
16
|
+
from .executors import LocalToolExecutor, ToolExecutor
|
|
17
|
+
from .guards import (
|
|
18
|
+
Decision,
|
|
19
|
+
Guard,
|
|
20
|
+
GuardContext,
|
|
21
|
+
GuardPipeline,
|
|
22
|
+
InjectionGuard,
|
|
23
|
+
PiiRedactionGuard,
|
|
24
|
+
PiiUrlGuard,
|
|
25
|
+
RecipientTrustGuard,
|
|
26
|
+
TierGuard,
|
|
27
|
+
UntrustedDataGuard,
|
|
28
|
+
secure_defaults,
|
|
29
|
+
wrap_as_untrusted_data,
|
|
30
|
+
)
|
|
31
|
+
from .mcp import MCPServer
|
|
32
|
+
from .model import ModelFn, ToolSchema
|
|
33
|
+
from .policy import AgentPolicy, Tier
|
|
34
|
+
from .pricing import cost_usd, register_price
|
|
35
|
+
from .providers import AnthropicModel, MockModel
|
|
36
|
+
from .serde import message_from_dict, message_to_dict, outcome_from_dict, outcome_to_dict
|
|
37
|
+
from .store import FileStore, MemoryStore, Store
|
|
38
|
+
from .streaming import (
|
|
39
|
+
AgentStreamEvent,
|
|
40
|
+
AnswerDelta,
|
|
41
|
+
Done,
|
|
42
|
+
StreamingModelFn,
|
|
43
|
+
ToolFinished,
|
|
44
|
+
ToolStarted,
|
|
45
|
+
)
|
|
46
|
+
from .subagents import subagent_tool
|
|
47
|
+
from .tools import Tool, ToolRegistry, tool
|
|
48
|
+
from .types import (
|
|
49
|
+
AgentOutcome,
|
|
50
|
+
Message,
|
|
51
|
+
ModelResponse,
|
|
52
|
+
Role,
|
|
53
|
+
ToolCall,
|
|
54
|
+
ToolResult,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
__version__ = "0.1.0"
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
"Agent",
|
|
61
|
+
"AgentError",
|
|
62
|
+
"AgentEvents",
|
|
63
|
+
"AgentOutcome",
|
|
64
|
+
"AgentPolicy",
|
|
65
|
+
"AgentStreamEvent",
|
|
66
|
+
"AnswerDelta",
|
|
67
|
+
"AnthropicModel",
|
|
68
|
+
"BudgetExceeded",
|
|
69
|
+
"ConfirmFn",
|
|
70
|
+
"ContextStrategy",
|
|
71
|
+
"Decision",
|
|
72
|
+
"Done",
|
|
73
|
+
"FileStore",
|
|
74
|
+
"Guard",
|
|
75
|
+
"GuardContext",
|
|
76
|
+
"GuardError",
|
|
77
|
+
"GuardPipeline",
|
|
78
|
+
"InjectionGuard",
|
|
79
|
+
"Interrupt",
|
|
80
|
+
"Limiter",
|
|
81
|
+
"LocalToolExecutor",
|
|
82
|
+
"MCPServer",
|
|
83
|
+
"Message",
|
|
84
|
+
"MemoryStore",
|
|
85
|
+
"MockModel",
|
|
86
|
+
"ModelFn",
|
|
87
|
+
"ModelResponse",
|
|
88
|
+
"PiiRedactionGuard",
|
|
89
|
+
"PiiUrlGuard",
|
|
90
|
+
"RecipientTrustGuard",
|
|
91
|
+
"Role",
|
|
92
|
+
"Store",
|
|
93
|
+
"StreamingModelFn",
|
|
94
|
+
"Tier",
|
|
95
|
+
"TierGuard",
|
|
96
|
+
"Tool",
|
|
97
|
+
"ToolCall",
|
|
98
|
+
"ToolError",
|
|
99
|
+
"ToolExecutor",
|
|
100
|
+
"ToolFinished",
|
|
101
|
+
"ToolRegistry",
|
|
102
|
+
"ToolResult",
|
|
103
|
+
"ToolSchema",
|
|
104
|
+
"ToolStarted",
|
|
105
|
+
"TrimRounds",
|
|
106
|
+
"TruncateToolOutputs",
|
|
107
|
+
"UntrustedDataGuard",
|
|
108
|
+
"__version__",
|
|
109
|
+
"always_approve",
|
|
110
|
+
"always_deny",
|
|
111
|
+
"bounded_gather",
|
|
112
|
+
"console_confirm",
|
|
113
|
+
"cost_usd",
|
|
114
|
+
"message_from_dict",
|
|
115
|
+
"message_to_dict",
|
|
116
|
+
"outcome_from_dict",
|
|
117
|
+
"outcome_to_dict",
|
|
118
|
+
"register_price",
|
|
119
|
+
"secure_defaults",
|
|
120
|
+
"subagent_tool",
|
|
121
|
+
"tool",
|
|
122
|
+
"wrap_as_untrusted_data",
|
|
123
|
+
]
|
agentix/agent.py
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
"""The agent loop.
|
|
2
|
+
|
|
3
|
+
Small and shared: call the model, and if it asked for tools, run them and feed
|
|
4
|
+
the results back, until the model produces a final answer or a budget is hit.
|
|
5
|
+
Everything load-bearing — the model, the tools, the policy — is injected. The
|
|
6
|
+
loop only enforces the resource budgets here; the security guards plug in
|
|
7
|
+
later (P3) without changing this control flow.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import inspect
|
|
14
|
+
from collections.abc import AsyncIterator, Iterable, Sequence
|
|
15
|
+
from contextlib import AbstractAsyncContextManager, nullcontext
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from .concurrency import Limiter
|
|
19
|
+
from .confirm import ConfirmFn
|
|
20
|
+
from .context import ContextStrategy
|
|
21
|
+
from .control import Interrupt
|
|
22
|
+
from .errors import AgentError
|
|
23
|
+
from .events import AgentEvents
|
|
24
|
+
from .executors import ToolExecutor
|
|
25
|
+
from .guards import Guard, GuardContext, GuardPipeline
|
|
26
|
+
from .model import ModelFn, ToolSchema
|
|
27
|
+
from .policy import AgentPolicy
|
|
28
|
+
from .serde import SCHEMA_VERSION, transcript_from_dicts, transcript_to_dicts
|
|
29
|
+
from .store import Store
|
|
30
|
+
from .streaming import (
|
|
31
|
+
AgentStreamEvent,
|
|
32
|
+
AnswerDelta,
|
|
33
|
+
Done,
|
|
34
|
+
ModelStreamEvent,
|
|
35
|
+
ResponseComplete,
|
|
36
|
+
TextDelta,
|
|
37
|
+
ToolFinished,
|
|
38
|
+
ToolStarted,
|
|
39
|
+
)
|
|
40
|
+
from .tools import Tool, ToolRegistry
|
|
41
|
+
from .types import AgentOutcome, Message, ModelResponse, Role, ToolCall
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Agent:
|
|
45
|
+
"""Drives the async agent loop around an injected model and tool executor.
|
|
46
|
+
|
|
47
|
+
Minimal usage::
|
|
48
|
+
|
|
49
|
+
agent = Agent(model=my_model, system_prompt="...")
|
|
50
|
+
outcome = await agent.run("Summarize today's tickets.")
|
|
51
|
+
|
|
52
|
+
The easiest way to add tools is the ``tools=`` argument — pass ``@tool``
|
|
53
|
+
functions (or a :class:`~agentix.tools.ToolRegistry`) and the agent derives
|
|
54
|
+
both the executor and the schemas the model sees::
|
|
55
|
+
|
|
56
|
+
agent = Agent(model=m, system_prompt="...", tools=[get_weather, add])
|
|
57
|
+
|
|
58
|
+
For full control (e.g. a sandboxed executor) supply ``tool_executor`` and
|
|
59
|
+
``tool_schemas`` directly instead.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
*,
|
|
65
|
+
model: ModelFn,
|
|
66
|
+
system_prompt: str,
|
|
67
|
+
policy: AgentPolicy | None = None,
|
|
68
|
+
tools: ToolRegistry | Iterable[Tool] | None = None,
|
|
69
|
+
tool_executor: ToolExecutor | None = None,
|
|
70
|
+
tool_schemas: Sequence[ToolSchema] | None = None,
|
|
71
|
+
guards: Iterable[Guard] | None = None,
|
|
72
|
+
confirm_fn: ConfirmFn | None = None,
|
|
73
|
+
events: AgentEvents | None = None,
|
|
74
|
+
store: Store | None = None,
|
|
75
|
+
model_limiter: Limiter | None = None,
|
|
76
|
+
context_strategy: ContextStrategy | None = None,
|
|
77
|
+
) -> None:
|
|
78
|
+
self.model = model
|
|
79
|
+
self.system_prompt = system_prompt
|
|
80
|
+
self.policy = policy or AgentPolicy()
|
|
81
|
+
self.store = store
|
|
82
|
+
# Optional shared limiter to bound concurrent model calls across a fleet.
|
|
83
|
+
self.model_limiter = model_limiter
|
|
84
|
+
# Optional compaction applied before each model call (opt-in).
|
|
85
|
+
self.context_strategy = context_strategy
|
|
86
|
+
|
|
87
|
+
# Guards are opt-in: no guards -> a clean loop. Pass
|
|
88
|
+
# `guards=secure_defaults()` (or your own list) to turn on protections.
|
|
89
|
+
self.guards = GuardPipeline(list(guards)) if guards is not None else GuardPipeline()
|
|
90
|
+
self.confirm_fn = confirm_fn
|
|
91
|
+
self.events = events or AgentEvents()
|
|
92
|
+
|
|
93
|
+
# `tools=` is the high-level path: build a registry that serves as both
|
|
94
|
+
# the executor and the schema source. Explicit tool_executor /
|
|
95
|
+
# tool_schemas still win if also provided.
|
|
96
|
+
if tools is not None:
|
|
97
|
+
registry = tools if isinstance(tools, ToolRegistry) else ToolRegistry(tools)
|
|
98
|
+
if tool_executor is None:
|
|
99
|
+
tool_executor = registry
|
|
100
|
+
if tool_schemas is None:
|
|
101
|
+
tool_schemas = registry.schemas
|
|
102
|
+
|
|
103
|
+
self.tool_executor = tool_executor
|
|
104
|
+
self.tool_schemas: list[ToolSchema] = list(tool_schemas or [])
|
|
105
|
+
|
|
106
|
+
# ── public entry points ───────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
async def run(
|
|
109
|
+
self,
|
|
110
|
+
user_request: str,
|
|
111
|
+
*,
|
|
112
|
+
run_id: str | None = None,
|
|
113
|
+
interrupt: Interrupt | None = None,
|
|
114
|
+
) -> AgentOutcome:
|
|
115
|
+
"""Run the loop to completion. If ``run_id`` is given and a ``store`` is
|
|
116
|
+
configured, the run is checkpointed after every step (resumable). Pass an
|
|
117
|
+
``Interrupt`` to stop the run at its next safe boundary."""
|
|
118
|
+
messages = self._seed_messages(user_request)
|
|
119
|
+
return await self._loop(messages, 0, 0, 0.0, run_id, self.store, interrupt)
|
|
120
|
+
|
|
121
|
+
async def resume(
|
|
122
|
+
self,
|
|
123
|
+
run_id: str,
|
|
124
|
+
*,
|
|
125
|
+
store: Store | None = None,
|
|
126
|
+
interrupt: Interrupt | None = None,
|
|
127
|
+
) -> AgentOutcome:
|
|
128
|
+
"""Reload a checkpointed run and continue the loop from where it stopped."""
|
|
129
|
+
effective = store or self.store
|
|
130
|
+
if effective is None:
|
|
131
|
+
raise AgentError("resume() requires a store (on the Agent or as an argument)")
|
|
132
|
+
state = await effective.load(run_id)
|
|
133
|
+
if state is None:
|
|
134
|
+
raise AgentError(f"no saved run found for run_id {run_id!r}")
|
|
135
|
+
messages = transcript_from_dicts(state["messages"])
|
|
136
|
+
return await self._loop(
|
|
137
|
+
messages,
|
|
138
|
+
int(state["steps"]),
|
|
139
|
+
int(state["tokens_used"]),
|
|
140
|
+
float(state.get("cost_usd", 0.0)),
|
|
141
|
+
run_id,
|
|
142
|
+
effective,
|
|
143
|
+
interrupt,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def run_sync(self, user_request: str, *, run_id: str | None = None) -> AgentOutcome:
|
|
147
|
+
"""Blocking convenience wrapper for scripts/CLIs. Do not call from
|
|
148
|
+
inside a running event loop."""
|
|
149
|
+
try:
|
|
150
|
+
asyncio.get_running_loop()
|
|
151
|
+
except RuntimeError:
|
|
152
|
+
return asyncio.run(self.run(user_request, run_id=run_id))
|
|
153
|
+
raise RuntimeError(
|
|
154
|
+
"run_sync() cannot be called from a running event loop; await run() instead."
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def resume_sync(self, run_id: str, *, store: Store | None = None) -> AgentOutcome:
|
|
158
|
+
"""Blocking wrapper around :meth:`resume`. Do not call from inside a
|
|
159
|
+
running event loop."""
|
|
160
|
+
try:
|
|
161
|
+
asyncio.get_running_loop()
|
|
162
|
+
except RuntimeError:
|
|
163
|
+
return asyncio.run(self.resume(run_id, store=store))
|
|
164
|
+
raise RuntimeError(
|
|
165
|
+
"resume_sync() cannot be called from a running event loop; await resume() instead."
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
async def stream(
|
|
169
|
+
self,
|
|
170
|
+
user_request: str,
|
|
171
|
+
*,
|
|
172
|
+
run_id: str | None = None,
|
|
173
|
+
interrupt: Interrupt | None = None,
|
|
174
|
+
) -> AsyncIterator[AgentStreamEvent]:
|
|
175
|
+
"""Run the loop, yielding events as they happen: ``AnswerDelta`` text
|
|
176
|
+
chunks, ``ToolStarted``/``ToolFinished`` around tool calls, and a final
|
|
177
|
+
``Done`` carrying the outcome.
|
|
178
|
+
|
|
179
|
+
Note: ``on_answer`` egress guards (e.g. PII redaction) cannot un-send
|
|
180
|
+
already-streamed deltas — the deltas are raw, but ``Done.outcome.answer``
|
|
181
|
+
is passed through the guards. Use :meth:`run` if you need the user-facing
|
|
182
|
+
text itself redacted before it is emitted.
|
|
183
|
+
"""
|
|
184
|
+
messages = self._seed_messages(user_request)
|
|
185
|
+
steps = 0
|
|
186
|
+
tokens_used = 0
|
|
187
|
+
cost_usd = 0.0
|
|
188
|
+
|
|
189
|
+
while steps < self.policy.max_steps:
|
|
190
|
+
if interrupt is not None and interrupt.triggered:
|
|
191
|
+
yield Done(await self._abort("interrupted", steps, tokens_used, cost_usd, messages))
|
|
192
|
+
return
|
|
193
|
+
steps += 1
|
|
194
|
+
|
|
195
|
+
messages = await self._compact(messages)
|
|
196
|
+
response: ModelResponse | None = None
|
|
197
|
+
async for event in self._model_stream(messages):
|
|
198
|
+
if isinstance(event, TextDelta):
|
|
199
|
+
yield AnswerDelta(event.text)
|
|
200
|
+
elif isinstance(event, ResponseComplete):
|
|
201
|
+
response = event.response
|
|
202
|
+
assert response is not None # stream always ends with ResponseComplete
|
|
203
|
+
await self.events.emit("on_model", messages, response)
|
|
204
|
+
|
|
205
|
+
tokens_used += response.tokens_used
|
|
206
|
+
cost_usd += response.cost_usd
|
|
207
|
+
stop = self._budget_stop(steps, tokens_used, cost_usd)
|
|
208
|
+
if stop is not None:
|
|
209
|
+
yield Done(await self._abort(stop, steps, tokens_used, cost_usd, messages))
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
if response.is_final:
|
|
213
|
+
answer = await self.guards.on_answer(response.text, GuardContext(self.policy))
|
|
214
|
+
messages.append(Message(Role.ASSISTANT, answer, trusted=True))
|
|
215
|
+
outcome = AgentOutcome(
|
|
216
|
+
status="completed",
|
|
217
|
+
answer=answer,
|
|
218
|
+
steps=steps,
|
|
219
|
+
tokens_used=tokens_used,
|
|
220
|
+
cost_usd=cost_usd,
|
|
221
|
+
transcript=messages,
|
|
222
|
+
)
|
|
223
|
+
await self.events.emit("on_final", outcome)
|
|
224
|
+
await self._checkpoint(self.store, run_id, steps, tokens_used, cost_usd, messages)
|
|
225
|
+
yield Done(outcome)
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
messages.append(self._assistant_tool_turn(response))
|
|
229
|
+
for call in response.tool_calls:
|
|
230
|
+
yield ToolStarted(call)
|
|
231
|
+
msg = await self._handle_call(call)
|
|
232
|
+
messages.append(msg)
|
|
233
|
+
yield ToolFinished(msg)
|
|
234
|
+
await self._checkpoint(self.store, run_id, steps, tokens_used, cost_usd, messages)
|
|
235
|
+
|
|
236
|
+
yield Done(await self._abort("max_steps_reached", steps, tokens_used, cost_usd, messages))
|
|
237
|
+
|
|
238
|
+
# ── the core loop ─────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
async def _loop(
|
|
241
|
+
self,
|
|
242
|
+
messages: list[Message],
|
|
243
|
+
steps: int,
|
|
244
|
+
tokens_used: int,
|
|
245
|
+
cost_usd: float,
|
|
246
|
+
run_id: str | None,
|
|
247
|
+
store: Store | None,
|
|
248
|
+
interrupt: Interrupt | None = None,
|
|
249
|
+
) -> AgentOutcome:
|
|
250
|
+
while steps < self.policy.max_steps:
|
|
251
|
+
# Cooperative interrupt: checked at a safe boundary (between steps).
|
|
252
|
+
if interrupt is not None and interrupt.triggered:
|
|
253
|
+
return await self._abort("interrupted", steps, tokens_used, cost_usd, messages)
|
|
254
|
+
steps += 1
|
|
255
|
+
|
|
256
|
+
messages = await self._compact(messages)
|
|
257
|
+
async with self._model_slot():
|
|
258
|
+
response = await self.model(messages, tools=self.tool_schemas)
|
|
259
|
+
await self.events.emit("on_model", messages, response)
|
|
260
|
+
tokens_used += response.tokens_used
|
|
261
|
+
cost_usd += response.cost_usd
|
|
262
|
+
stop = self._budget_stop(steps, tokens_used, cost_usd)
|
|
263
|
+
if stop is not None:
|
|
264
|
+
return await self._abort(stop, steps, tokens_used, cost_usd, messages)
|
|
265
|
+
|
|
266
|
+
if response.is_final:
|
|
267
|
+
# GUARD: egress filter on the answer to the user (e.g. PII
|
|
268
|
+
# redaction). No-op when no guard implements on_answer.
|
|
269
|
+
answer = await self.guards.on_answer(
|
|
270
|
+
response.text, GuardContext(self.policy)
|
|
271
|
+
)
|
|
272
|
+
messages.append(Message(Role.ASSISTANT, answer, trusted=True))
|
|
273
|
+
outcome = AgentOutcome(
|
|
274
|
+
status="completed",
|
|
275
|
+
answer=answer,
|
|
276
|
+
steps=steps,
|
|
277
|
+
tokens_used=tokens_used,
|
|
278
|
+
cost_usd=cost_usd,
|
|
279
|
+
transcript=messages,
|
|
280
|
+
)
|
|
281
|
+
await self.events.emit("on_final", outcome)
|
|
282
|
+
return outcome
|
|
283
|
+
|
|
284
|
+
messages.append(self._assistant_tool_turn(response))
|
|
285
|
+
for call in response.tool_calls:
|
|
286
|
+
messages.append(await self._handle_call(call))
|
|
287
|
+
|
|
288
|
+
# Checkpoint after each completed step so a crash mid-next-step can resume.
|
|
289
|
+
await self._checkpoint(store, run_id, steps, tokens_used, cost_usd, messages)
|
|
290
|
+
|
|
291
|
+
return await self._abort("max_steps_reached", steps, tokens_used, cost_usd, messages)
|
|
292
|
+
|
|
293
|
+
def _budget_stop(self, steps: int, tokens_used: int, cost_usd: float) -> str | None:
|
|
294
|
+
"""Return an abort reason if a resource budget is exceeded, else None."""
|
|
295
|
+
if tokens_used > self.policy.max_tokens_budget:
|
|
296
|
+
return "budget_exceeded"
|
|
297
|
+
if self.policy.max_budget_usd is not None and cost_usd > self.policy.max_budget_usd:
|
|
298
|
+
return "budget_usd_exceeded"
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
async def _model_stream(self, messages: list[Message]) -> AsyncIterator[ModelStreamEvent]:
|
|
302
|
+
"""Yield model stream events, falling back to a one-shot call for models
|
|
303
|
+
that don't implement ``stream``."""
|
|
304
|
+
streamer = getattr(self.model, "stream", None)
|
|
305
|
+
if streamer is not None:
|
|
306
|
+
# Hold the slot for the whole stream (the connection stays open).
|
|
307
|
+
async with self._model_slot():
|
|
308
|
+
async for event in streamer(messages, tools=self.tool_schemas):
|
|
309
|
+
yield event
|
|
310
|
+
return
|
|
311
|
+
async with self._model_slot():
|
|
312
|
+
response = await self.model(messages, tools=self.tool_schemas)
|
|
313
|
+
if response.text:
|
|
314
|
+
yield TextDelta(response.text)
|
|
315
|
+
yield ResponseComplete(response)
|
|
316
|
+
|
|
317
|
+
def _model_slot(self) -> AbstractAsyncContextManager[Any]:
|
|
318
|
+
"""The limiter context, or a no-op when no limiter is configured."""
|
|
319
|
+
return self.model_limiter if self.model_limiter is not None else nullcontext()
|
|
320
|
+
|
|
321
|
+
async def _compact(self, messages: list[Message]) -> list[Message]:
|
|
322
|
+
"""Apply the context strategy (if any) before a model call."""
|
|
323
|
+
if self.context_strategy is None:
|
|
324
|
+
return messages
|
|
325
|
+
before = len(messages)
|
|
326
|
+
compacted = await self.context_strategy.compact(messages)
|
|
327
|
+
if compacted is not messages: # strategy signals a change by new identity
|
|
328
|
+
await self.events.emit("on_compact", before, len(compacted))
|
|
329
|
+
return compacted
|
|
330
|
+
|
|
331
|
+
def _seed_messages(self, user_request: str) -> list[Message]:
|
|
332
|
+
# Trust boundary: only the system prompt and the genuine user request
|
|
333
|
+
# are trusted as instructions. Tool output never is.
|
|
334
|
+
return [
|
|
335
|
+
Message(Role.SYSTEM, self.system_prompt, trusted=True),
|
|
336
|
+
Message(Role.USER, user_request, trusted=True),
|
|
337
|
+
]
|
|
338
|
+
|
|
339
|
+
@staticmethod
|
|
340
|
+
def _assistant_tool_turn(response: ModelResponse) -> Message:
|
|
341
|
+
# Record the assistant turn that requested the tools. Keep the full
|
|
342
|
+
# ToolCall objects (id + name + args), not just names — provider
|
|
343
|
+
# adapters need them to faithfully replay the turn next round.
|
|
344
|
+
return Message(
|
|
345
|
+
Role.ASSISTANT,
|
|
346
|
+
response.text,
|
|
347
|
+
trusted=True,
|
|
348
|
+
meta={"tool_calls": list(response.tool_calls)},
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
async def _checkpoint(
|
|
352
|
+
self,
|
|
353
|
+
store: Store | None,
|
|
354
|
+
run_id: str | None,
|
|
355
|
+
steps: int,
|
|
356
|
+
tokens_used: int,
|
|
357
|
+
cost_usd: float,
|
|
358
|
+
messages: list[Message],
|
|
359
|
+
) -> None:
|
|
360
|
+
if store is None or run_id is None:
|
|
361
|
+
return
|
|
362
|
+
await store.save(
|
|
363
|
+
run_id,
|
|
364
|
+
{
|
|
365
|
+
"run_id": run_id,
|
|
366
|
+
"schema_version": SCHEMA_VERSION,
|
|
367
|
+
"steps": steps,
|
|
368
|
+
"tokens_used": tokens_used,
|
|
369
|
+
"cost_usd": cost_usd,
|
|
370
|
+
"messages": transcript_to_dicts(messages),
|
|
371
|
+
},
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
# ── per-call handling ─────────────────────────────────────────────────
|
|
375
|
+
|
|
376
|
+
async def _handle_call(self, call: ToolCall) -> Message:
|
|
377
|
+
await self.events.emit("on_tool_call", call)
|
|
378
|
+
|
|
379
|
+
if self.tool_executor is None:
|
|
380
|
+
return self._tool_msg(
|
|
381
|
+
call, "REFUSED: no tool executor is configured.", ok=False
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
ctx = GuardContext(self.policy)
|
|
385
|
+
|
|
386
|
+
# GUARD: pre-execution checks (tiers, PII, recipient-trust, ...).
|
|
387
|
+
decision = await self.guards.before_call(call, ctx)
|
|
388
|
+
await self.events.emit("on_guard_decision", call, decision)
|
|
389
|
+
if decision.is_deny:
|
|
390
|
+
return self._tool_msg(call, f"REFUSED: {decision.reason}", ok=False)
|
|
391
|
+
if decision.is_confirm:
|
|
392
|
+
approved = await self._confirm(call, decision.reason)
|
|
393
|
+
await self.events.emit("on_confirm", call, approved)
|
|
394
|
+
if not approved:
|
|
395
|
+
return self._tool_msg(call, "User declined this action.", ok=False)
|
|
396
|
+
|
|
397
|
+
# GUARD: execute inside the executor with policy-enforced limits.
|
|
398
|
+
result = await self.tool_executor(
|
|
399
|
+
call,
|
|
400
|
+
network_allowlist=self.policy.network_allowlist,
|
|
401
|
+
timeout_s=self.policy.tool_timeout_s,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
# GUARD: sanitize output before it re-enters context (injection scan,
|
|
405
|
+
# untrusted-data wrapping). Tool output is data, never instructions.
|
|
406
|
+
content = await self.guards.after_output(call, result.content, ctx)
|
|
407
|
+
msg = self._tool_msg(call, content, ok=result.ok)
|
|
408
|
+
await self.events.emit("on_tool_result", call, msg)
|
|
409
|
+
return msg
|
|
410
|
+
|
|
411
|
+
async def _confirm(self, call: ToolCall, reason: str) -> bool:
|
|
412
|
+
# Fail closed: a confirmation was required but no confirmer is wired.
|
|
413
|
+
if self.confirm_fn is None:
|
|
414
|
+
return False
|
|
415
|
+
result = self.confirm_fn(self._describe(call, reason))
|
|
416
|
+
if inspect.isawaitable(result):
|
|
417
|
+
result = await result
|
|
418
|
+
return bool(result)
|
|
419
|
+
|
|
420
|
+
# ── helpers ───────────────────────────────────────────────────────────
|
|
421
|
+
|
|
422
|
+
@staticmethod
|
|
423
|
+
def _tool_msg(call: ToolCall, content: str, *, ok: bool) -> Message:
|
|
424
|
+
return Message(
|
|
425
|
+
Role.TOOL,
|
|
426
|
+
content,
|
|
427
|
+
trusted=False,
|
|
428
|
+
name=call.name,
|
|
429
|
+
meta={"call_id": call.id, "ok": ok},
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
@staticmethod
|
|
433
|
+
def _describe(call: ToolCall, reason: str = "") -> str:
|
|
434
|
+
args_preview = ", ".join(f"{k}={v!r}" for k, v in call.args.items())
|
|
435
|
+
prefix = f"{reason}. " if reason else ""
|
|
436
|
+
return f"{prefix}About to run '{call.name}' with: {args_preview}. Approve?"
|
|
437
|
+
|
|
438
|
+
async def _abort(
|
|
439
|
+
self,
|
|
440
|
+
reason: str,
|
|
441
|
+
steps: int,
|
|
442
|
+
tokens: int,
|
|
443
|
+
cost_usd: float,
|
|
444
|
+
transcript: list[Message],
|
|
445
|
+
) -> AgentOutcome:
|
|
446
|
+
outcome = AgentOutcome(
|
|
447
|
+
status="aborted",
|
|
448
|
+
reason=reason,
|
|
449
|
+
steps=steps,
|
|
450
|
+
tokens_used=tokens,
|
|
451
|
+
cost_usd=cost_usd,
|
|
452
|
+
transcript=transcript,
|
|
453
|
+
)
|
|
454
|
+
await self.events.emit("on_final", outcome)
|
|
455
|
+
return outcome
|
agentix/concurrency.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Concurrency control for running agents at scale.
|
|
2
|
+
|
|
3
|
+
At "thousands of agents" the scarce resources are the provider connection pool,
|
|
4
|
+
provider rate limits, and memory (one transcript per in-flight run). agentix
|
|
5
|
+
imposes no cap by default; these primitives let you add one.
|
|
6
|
+
|
|
7
|
+
* :class:`Limiter` — a shareable async semaphore. Inject it into an ``Agent``
|
|
8
|
+
(``model_limiter=``) to bound concurrent **model calls** across a whole
|
|
9
|
+
fleet of agents — the right tool for a web server where each request spawns
|
|
10
|
+
its own ``run()`` and there's no single place to gather.
|
|
11
|
+
* :func:`bounded_gather` — run many awaitables with a hard concurrency cap.
|
|
12
|
+
The right tool for batch jobs, where it also bounds peak memory by limiting
|
|
13
|
+
how many runs are alive at once.
|
|
14
|
+
|
|
15
|
+
Both bind to the event loop on first use — create and share them **within one
|
|
16
|
+
running loop** (don't reuse one Limiter across separate ``asyncio.run`` calls).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
from collections.abc import Awaitable, Sequence
|
|
23
|
+
from contextlib import AbstractAsyncContextManager
|
|
24
|
+
from types import TracebackType
|
|
25
|
+
from typing import TypeVar
|
|
26
|
+
|
|
27
|
+
_T = TypeVar("_T")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Limiter(AbstractAsyncContextManager["Limiter"]):
|
|
31
|
+
"""An async concurrency limiter usable as an ``async with`` context.
|
|
32
|
+
|
|
33
|
+
Share one instance across many agents to cap their combined in-flight
|
|
34
|
+
operations (e.g. concurrent provider requests)::
|
|
35
|
+
|
|
36
|
+
limiter = Limiter(50)
|
|
37
|
+
agent = Agent(model=..., system_prompt=..., model_limiter=limiter)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, max_concurrency: int) -> None:
|
|
41
|
+
if max_concurrency < 1:
|
|
42
|
+
raise ValueError("max_concurrency must be >= 1")
|
|
43
|
+
self.max_concurrency = max_concurrency
|
|
44
|
+
self._sem = asyncio.Semaphore(max_concurrency)
|
|
45
|
+
|
|
46
|
+
async def __aenter__(self) -> Limiter:
|
|
47
|
+
await self._sem.acquire()
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
async def __aexit__(
|
|
51
|
+
self,
|
|
52
|
+
exc_type: type[BaseException] | None,
|
|
53
|
+
exc: BaseException | None,
|
|
54
|
+
tb: TracebackType | None,
|
|
55
|
+
) -> None:
|
|
56
|
+
self._sem.release()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def bounded_gather(
|
|
60
|
+
aws: Sequence[Awaitable[_T]], *, limit: int
|
|
61
|
+
) -> list[_T]:
|
|
62
|
+
"""Like :func:`asyncio.gather`, but at most ``limit`` awaitables run at once.
|
|
63
|
+
|
|
64
|
+
Results are returned in the original order. Use this to launch many agent
|
|
65
|
+
runs without flooding the provider or holding every transcript in memory::
|
|
66
|
+
|
|
67
|
+
outcomes = await bounded_gather(
|
|
68
|
+
[agent.run(q) for q in questions], limit=20
|
|
69
|
+
)
|
|
70
|
+
"""
|
|
71
|
+
if limit < 1:
|
|
72
|
+
raise ValueError("limit must be >= 1")
|
|
73
|
+
sem = asyncio.Semaphore(limit)
|
|
74
|
+
|
|
75
|
+
async def _run(aw: Awaitable[_T]) -> _T:
|
|
76
|
+
async with sem:
|
|
77
|
+
return await aw
|
|
78
|
+
|
|
79
|
+
return await asyncio.gather(*(_run(a) for a in aws))
|
agentix/confirm.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Human-in-the-loop confirmation.
|
|
2
|
+
|
|
3
|
+
A ``ConfirmFn`` receives a human-readable description of a pending action and
|
|
4
|
+
returns True only on an explicit "yes". It may be sync or async — async lets a
|
|
5
|
+
web/server app await a real user decision without blocking the event loop.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Awaitable, Callable
|
|
11
|
+
from typing import Union
|
|
12
|
+
|
|
13
|
+
#: Returns True to approve. Sync or async; the loop awaits awaitable results.
|
|
14
|
+
ConfirmFn = Callable[[str], Union[bool, "Awaitable[bool]"]]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def always_approve(description: str) -> bool:
|
|
18
|
+
"""Approve everything. For tests/automation only — never in production."""
|
|
19
|
+
return True
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def always_deny(description: str) -> bool:
|
|
23
|
+
"""Decline everything. The safe default when no human is available."""
|
|
24
|
+
return False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def console_confirm(description: str) -> bool:
|
|
28
|
+
"""Prompt on the terminal. Blocks for real stdin input — CLI use only."""
|
|
29
|
+
answer = input(f"{description} [y/N] ").strip().lower()
|
|
30
|
+
return answer in {"y", "yes"}
|