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 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"}