framework-agents 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 (41) hide show
  1. framework_agents/__init__.py +345 -0
  2. framework_agents/a2a_client.py +360 -0
  3. framework_agents/a2a_server.py +495 -0
  4. framework_agents/agent.py +920 -0
  5. framework_agents/checkpoint.py +231 -0
  6. framework_agents/config.py +200 -0
  7. framework_agents/conversation.py +249 -0
  8. framework_agents/errors.py +47 -0
  9. framework_agents/evals.py +127 -0
  10. framework_agents/mcp_client.py +477 -0
  11. framework_agents/memory.py +103 -0
  12. framework_agents/memory_compaction.py +63 -0
  13. framework_agents/middleware.py +1302 -0
  14. framework_agents/models/__init__.py +100 -0
  15. framework_agents/models/anthropic.py +339 -0
  16. framework_agents/models/capabilities.py +45 -0
  17. framework_agents/models/ollama.py +47 -0
  18. framework_agents/models/openai_compatible.py +409 -0
  19. framework_agents/observability.py +84 -0
  20. framework_agents/ontology_review.py +321 -0
  21. framework_agents/orchestration.py +169 -0
  22. framework_agents/pricing.py +63 -0
  23. framework_agents/prompts.py +132 -0
  24. framework_agents/rag.py +123 -0
  25. framework_agents/remote_sparql.py +213 -0
  26. framework_agents/runtime.py +88 -0
  27. framework_agents/sandbox.py +327 -0
  28. framework_agents/semantic.py +1614 -0
  29. framework_agents/serve.py +436 -0
  30. framework_agents/sessions.py +249 -0
  31. framework_agents/skills.py +213 -0
  32. framework_agents/state.py +89 -0
  33. framework_agents/streaming.py +74 -0
  34. framework_agents/swarm.py +318 -0
  35. framework_agents/tools.py +114 -0
  36. framework_agents/tracing.py +131 -0
  37. framework_agents/types.py +262 -0
  38. framework_agents/workflows.py +130 -0
  39. framework_agents-0.1.0.dist-info/METADATA +118 -0
  40. framework_agents-0.1.0.dist-info/RECORD +41 -0
  41. framework_agents-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,1302 @@
1
+ """Middleware — control the agent loop without forking it. Parity: src/middleware/index.ts.
2
+
3
+ Execution order (pinned, matches TS):
4
+ - before_model / before_tool: registration order
5
+ - after_model / after_tool: REVERSE registration order
6
+ - wrap_model / around_tool: nested, first registered = outermost (TS reduceRight)
7
+
8
+ Pinned deviations from the TS file (per port plan):
9
+ - Verdict-driven retry: a call is retried only when ``get_retryable`` says
10
+ True; False (e.g. every fatal AgentError: budgets, tool-call limits) always
11
+ passes through untouched, unknown verdicts are not auto-retried.
12
+ - PII mask keeps the first char + dots, hash is sha256 hexdigest[:16];
13
+ "block" raises ToolRejection on an output match.
14
+ - Pinned deviations of the batch-2 ports (the rest of the TS built-ins):
15
+ - model_fallback replays the SAME request to each fallback in order and
16
+ re-raises the LAST error when every provider fails.
17
+ - summarization_middleware defaults the summary prefix to
18
+ "Prior context summary" (TS: "Summary of earlier context:"); the summary
19
+ call is a single user message with the transcript embedded (TS
20
+ summarizeWithModel shape).
21
+ - ClearToolUsesEdit STRIPS old tool-call/tool-result PAIRS (matched by
22
+ tool_call_id); the TS version only replaces old tool OUTPUTS with a
23
+ placeholder. Pinned messages (ChatMessage.pinned) always survive -
24
+ pinning either half of a pair keeps the whole pair.
25
+ - llm_tool_selector_middleware degrades to the ORIGINAL unfiltered tool
26
+ list when the selector model throws or picks nothing.
27
+ - tool_emulator_middleware records tool descriptions from before_model so
28
+ the emulation prompt can carry them (TS sends name + args only).
29
+ - filesystem tools return ERROR FEEDBACK STRINGS for escapes / edit
30
+ misses instead of raising AgentError (TS FS_ESCAPE / FS_NO_MATCH) so the
31
+ run continues; paths are validated with pathlib resolve + relative_to.
32
+ - create_sub_agent_middleware returns model-visible feedback for unknown
33
+ subagents (TS raises AgentError SUBAGENT_UNKNOWN).
34
+ (The "old" fixed-backoff ``toolRetry`` is folded into ``tool_retry``.)
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import asyncio
40
+ import hashlib
41
+ import inspect
42
+ import json
43
+ import os
44
+ import random
45
+ import re
46
+ from dataclasses import dataclass, field
47
+ from pathlib import Path
48
+ from typing import Any, Awaitable, Callable, Iterable, Optional, Protocol, Union
49
+
50
+ from pydantic import BaseModel
51
+
52
+ from .conversation import history_tokens
53
+ from .errors import AgentError, get_retryable
54
+ from .models import CompleteOptions, ModelCompletion, ModelProvider, ResponseFormatWire, ToolWire
55
+ from .tools import Tool, ToolContext, define_tool
56
+ from .types import ChatMessage, ReasoningBlock, TextBlock, TokenUsage, ToolCallBlock, text_of
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Shapes
61
+ # ---------------------------------------------------------------------------
62
+
63
+
64
+ @dataclass
65
+ class HookContext:
66
+ """Per-step hook context. ``invocation_state`` is shared by reference
67
+ across steps (and across suspend/resume) and never sent to the model."""
68
+
69
+ step: int
70
+ invocation_state: dict[str, Any] = field(default_factory=dict)
71
+
72
+
73
+ @dataclass
74
+ class ModelRequest:
75
+ """CompleteOptions-shaped request the model hooks see."""
76
+
77
+ messages: list[ChatMessage]
78
+ tools: list[ToolWire] = field(default_factory=list)
79
+ response_format: ResponseFormatWire | None = None
80
+
81
+
82
+ @dataclass
83
+ class ToolCallRef:
84
+ name: str
85
+ args: Any = None
86
+
87
+
88
+ ModelHookNext = Callable[[ModelRequest], Awaitable[ModelCompletion]]
89
+ ToolHookNext = Callable[[], Awaitable[Any]]
90
+
91
+
92
+ class AgentMiddleware(Protocol):
93
+ """Structural protocol; every hook is optional. Hooks may be sync or
94
+ async (sync returns are unwrapped like TS's union return types)."""
95
+
96
+ name: str
97
+ tools: list[Tool]
98
+
99
+ def before_model(self, req: ModelRequest, ctx: HookContext) -> Optional[ModelRequest]: ...
100
+ def wrap_model(self, req: ModelRequest, next_fn: ModelHookNext, ctx: HookContext) -> ModelCompletion: ...
101
+ def after_model(self, res: ModelCompletion, req: ModelRequest, ctx: HookContext) -> Optional[ModelCompletion]: ...
102
+ def before_tool(self, call: ToolCallRef, ctx: HookContext) -> Optional[ToolCallRef]: ...
103
+ def around_tool(self, call: ToolCallRef, next_fn: ToolHookNext, ctx: HookContext) -> Any: ...
104
+ def after_tool(self, call: ToolCallRef, output: Any, ctx: HookContext) -> Any: ...
105
+
106
+
107
+ class Middleware:
108
+ """Concrete duck-typed AgentMiddleware for the built-ins (and users who
109
+ prefer composition over subclassing). Unset hooks stay absent."""
110
+
111
+ def __init__(
112
+ self,
113
+ name: str,
114
+ *,
115
+ tools: Optional[Iterable[Tool]] = None,
116
+ before_model: Optional[Callable[..., Any]] = None,
117
+ wrap_model: Optional[Callable[..., Any]] = None,
118
+ after_model: Optional[Callable[..., Any]] = None,
119
+ before_tool: Optional[Callable[..., Any]] = None,
120
+ around_tool: Optional[Callable[..., Any]] = None,
121
+ after_tool: Optional[Callable[..., Any]] = None,
122
+ ) -> None:
123
+ self.name = name
124
+ self.tools = list(tools) if tools is not None else []
125
+ if before_model is not None:
126
+ self.before_model = before_model
127
+ if wrap_model is not None:
128
+ self.wrap_model = wrap_model
129
+ if after_model is not None:
130
+ self.after_model = after_model
131
+ if before_tool is not None:
132
+ self.before_tool = before_tool
133
+ if around_tool is not None:
134
+ self.around_tool = around_tool
135
+ if after_tool is not None:
136
+ self.after_tool = after_tool
137
+
138
+
139
+ def create_middleware(m: Any) -> Any:
140
+ """Identity factory — documents intent and locks the type."""
141
+ return m
142
+
143
+
144
+ async def _maybe_await(value: Any) -> Any:
145
+ if inspect.isawaitable(value):
146
+ return await value
147
+ return value
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # Hook chains (shared by the agent loop)
152
+ # ---------------------------------------------------------------------------
153
+
154
+
155
+ async def run_before_model(middlewares: list[Any], req: ModelRequest, ctx: HookContext) -> ModelRequest:
156
+ for mw in middlewares:
157
+ hook = getattr(mw, "before_model", None)
158
+ if hook is None:
159
+ continue
160
+ new = await _maybe_await(hook(req, ctx))
161
+ if new is not None:
162
+ req = new
163
+ return req
164
+
165
+
166
+ async def run_after_model(middlewares: list[Any], res: ModelCompletion, req: ModelRequest, ctx: HookContext) -> ModelCompletion:
167
+ for mw in reversed(middlewares):
168
+ hook = getattr(mw, "after_model", None)
169
+ if hook is None:
170
+ continue
171
+ new = await _maybe_await(hook(res, req, ctx))
172
+ if new is not None:
173
+ res = new
174
+ return res
175
+
176
+
177
+ def compose_model_call(
178
+ middlewares: list[Any], base: ModelHookNext, ctx: HookContext
179
+ ) -> Callable[[ModelRequest], Awaitable[ModelCompletion]]:
180
+ """wrap_model chain, nested first-registered-outermost (TS reduceRight)."""
181
+
182
+ def bind(wrap: Callable[..., Any], next_fn: ModelHookNext) -> ModelHookNext:
183
+ async def wrapped(req: ModelRequest) -> ModelCompletion:
184
+ return await _maybe_await(wrap(req, next_fn, ctx))
185
+
186
+ return wrapped
187
+
188
+ async def call(req: ModelRequest) -> ModelCompletion:
189
+ fn = base
190
+ for mw in reversed(middlewares):
191
+ wrap = getattr(mw, "wrap_model", None)
192
+ if wrap is None:
193
+ continue
194
+ fn = bind(wrap, fn)
195
+ return await fn(req)
196
+
197
+ return call
198
+
199
+
200
+ async def run_before_tool(middlewares: list[Any], call: ToolCallRef, ctx: HookContext) -> ToolCallRef:
201
+ for mw in middlewares:
202
+ hook = getattr(mw, "before_tool", None)
203
+ if hook is None:
204
+ continue
205
+ new = await _maybe_await(hook(call, ctx))
206
+ if new is not None:
207
+ call = new
208
+ return call
209
+
210
+
211
+ def compose_tool_call(
212
+ middlewares: list[Any], base: ToolHookNext, call: ToolCallRef, ctx: HookContext
213
+ ) -> Callable[[], Awaitable[Any]]:
214
+ """around_tool chain, nested first-registered-outermost (TS reduceRight)."""
215
+
216
+ def bind(wrap: Callable[..., Any], next_fn: ToolHookNext) -> ToolHookNext:
217
+ async def wrapped() -> Any:
218
+ return await _maybe_await(wrap(call, next_fn, ctx))
219
+
220
+ return wrapped
221
+
222
+ async def run() -> Any:
223
+ fn = base
224
+ for mw in reversed(middlewares):
225
+ wrap = getattr(mw, "around_tool", None)
226
+ if wrap is None:
227
+ continue
228
+ fn = bind(wrap, fn)
229
+ return await fn()
230
+
231
+ return run
232
+
233
+
234
+ async def run_after_tool(middlewares: list[Any], call: ToolCallRef, output: Any, ctx: HookContext) -> Any:
235
+ for mw in reversed(middlewares):
236
+ hook = getattr(mw, "after_tool", None)
237
+ if hook is None:
238
+ continue
239
+ new = await _maybe_await(hook(call, output, ctx))
240
+ if new is not None:
241
+ output = new
242
+ return output
243
+
244
+
245
+ # ---------------------------------------------------------------------------
246
+ # Errors
247
+ # ---------------------------------------------------------------------------
248
+
249
+
250
+ class ToolRejection(Exception):
251
+ """Thrown to skip execution with model-visible feedback (the loop turns
252
+ this into a tool message and continues)."""
253
+
254
+ def __init__(self, feedback: str) -> None:
255
+ super().__init__(feedback)
256
+ self.feedback = feedback
257
+
258
+
259
+ class HitlInterrupt(Exception):
260
+ """Thrown to suspend a run for human review. The caller catches it,
261
+ stores a decision in ``invocation_state["__hitl_decisions"]``, and
262
+ resumes (the pinned decision-key semantics live in human_in_the_loop)."""
263
+
264
+ def __init__(self, interrupt_id: str, tool: str, args: Any = None) -> None:
265
+ super().__init__(f'Approval needed for tool "{tool}" (interrupt {interrupt_id})')
266
+ self.interrupt_id = interrupt_id
267
+ self.tool = tool
268
+ self.args = args
269
+
270
+
271
+ HITL_DECISIONS_KEY = "__hitl_decisions"
272
+
273
+
274
+ def hitl_key(tool: str, args: Any) -> str:
275
+ """Stable key binding a decision to one tool call.
276
+
277
+ Pinned: ``f"{tool}:{json.dumps(args, sort_keys=False, default=str)}"``.
278
+ """
279
+ return f"{tool}:{json.dumps(args, sort_keys=False, default=str)}"
280
+
281
+
282
+ HitlDecision = Union[str, dict[str, Any]] # "approve" | {"reject": msg} | {"edit": args} | {"suspend": True}
283
+
284
+
285
+ def human_in_the_loop(
286
+ interrupt_on: dict[str, Any],
287
+ decide: Optional[Callable[[dict[str, Any]], Any]] = None,
288
+ ) -> Any:
289
+ """HITL approval for tools listed in ``interrupt_on``.
290
+
291
+ Stored decisions are read from ``ctx.invocation_state["__hitl_decisions"]``
292
+ by EXACT ``hitl_key(tool, args)`` first, then by the BARE TOOL NAME.
293
+ The bare-name wildcard is a pinned semantic for resume: a model re-issuing
294
+ a call regenerates arg text, so exact keys never converge; a decision
295
+ stored under the tool name approves the re-issued call with different args.
296
+
297
+ ``interrupt_on`` values: ``True`` (approve/edit/reject allowed) or a dict
298
+ like ``{"allowed_decisions": ["approve", "reject"]}``.
299
+ """
300
+ default_allowed = ["approve", "edit", "reject"]
301
+
302
+ async def before_tool(call: ToolCallRef, ctx: HookContext) -> Optional[ToolCallRef]:
303
+ rule = interrupt_on.get(call.name)
304
+ if rule is None:
305
+ return None
306
+ if rule is True:
307
+ allowed = default_allowed
308
+ elif isinstance(rule, dict):
309
+ allowed = list(rule.get("allowed_decisions", default_allowed))
310
+ else:
311
+ allowed = default_allowed
312
+ interrupt_id = hitl_key(call.name, call.args)
313
+ decisions = ctx.invocation_state.get(HITL_DECISIONS_KEY) or {}
314
+ stored = decisions.get(interrupt_id)
315
+ if stored is None:
316
+ stored = decisions.get(call.name) # bare tool-name wildcard
317
+ if stored is not None:
318
+ decision: Any = stored
319
+ elif decide is not None:
320
+ decision = await _maybe_await(
321
+ decide({"name": call.name, "args": call.args, "allowed": list(allowed), "interrupt_id": interrupt_id})
322
+ )
323
+ else:
324
+ decision = {"suspend": True}
325
+ if isinstance(decision, dict) and decision.get("suspend"):
326
+ raise HitlInterrupt(interrupt_id, call.name, call.args)
327
+ if decision == "approve":
328
+ if "approve" not in allowed:
329
+ raise AgentError("HITL_DECISION", f'"approve" not allowed for {call.name}', False)
330
+ return None
331
+ if isinstance(decision, dict) and "edit" in decision:
332
+ if "edit" not in allowed:
333
+ raise AgentError("HITL_DECISION", f'"edit" not allowed for {call.name}', False)
334
+ return ToolCallRef(name=call.name, args=decision["edit"])
335
+ message = decision.get("reject") if isinstance(decision, dict) else None
336
+ if "reject" not in allowed:
337
+ raise AgentError("HITL_DECISION", f'"reject" not allowed for {call.name}', False)
338
+ raise ToolRejection(str(message) if message else f"User rejected {call.name}. Do not retry this tool call.")
339
+
340
+ return Middleware("human-in-the-loop", before_tool=before_tool)
341
+
342
+
343
+ # ---------------------------------------------------------------------------
344
+ # Retry (shared backoff + verdict semantics)
345
+ # ---------------------------------------------------------------------------
346
+
347
+
348
+ def _backoff_delay_ms(attempt: int, initial_delay_ms: float, backoff_factor: float, max_delay_ms: float, jitter: bool) -> float:
349
+ grown = initial_delay_ms * (backoff_factor**attempt)
350
+ capped = min(grown, max_delay_ms)
351
+ if not jitter:
352
+ return capped
353
+ return capped * (0.75 + random.random() * 0.5)
354
+
355
+
356
+ def _should_retry(err: BaseException, retry_on: Any) -> bool:
357
+ """Retryable verdict comes from ``get_retryable`` — True retries, False
358
+ never does (tool-call limits / budgets pass straight through), and an
359
+ unknown verdict only retries when an explicit ``retry_on`` filter says
360
+ so. An optional ``retry_on`` (callable or exception-class list) further
361
+ restricts retries but can never override an explicit False verdict."""
362
+ verdict = get_retryable(err)
363
+ if verdict is False:
364
+ return False
365
+ if retry_on is None:
366
+ return verdict is True
367
+ if isinstance(retry_on, (list, tuple)):
368
+ return isinstance(err, tuple(retry_on))
369
+ return bool(retry_on(err))
370
+
371
+
372
+ def _sleep(backoff_ms: float) -> Awaitable[None]:
373
+ return asyncio.sleep(backoff_ms / 1000.0) if backoff_ms > 0 else asyncio.sleep(0)
374
+
375
+
376
+ def _err_message(err: BaseException) -> str:
377
+ return str(err) or err.__class__.__name__
378
+
379
+
380
+ RetryOn = Union[Callable[[BaseException], bool], list[type[BaseException]], None]
381
+ OnFailure = Union[str, Callable[[BaseException], str]] # "error" | "continue" | fn
382
+
383
+
384
+ def model_retry(
385
+ max_retries: int = 3,
386
+ initial_delay_ms: float = 1000,
387
+ backoff_factor: float = 2,
388
+ max_delay_ms: float = 60000,
389
+ jitter: bool = True,
390
+ retry_on: RetryOn = None,
391
+ on_failure: OnFailure = "error",
392
+ ) -> Any:
393
+ """wrapModel middleware retrying model calls with exponential backoff.
394
+
395
+ Verdicts: ``get_retryable`` True retries, False never (a tool-call-limit
396
+ or budget AgentError pass-through is NEVER retried), unknown only via an
397
+ explicit ``retry_on``. ``on_failure="error"`` re-raises the last error,
398
+ a callable synthesizes a completion with its text, and ``"continue"``
399
+ synthesizes a thin-text completion so the run ends gracefully.
400
+
401
+ ``on_failure`` applies ONLY to genuine unexpected failures: an
402
+ ``AgentError`` (control errors like budget/handoff exhaust their verdicts
403
+ first, then raise) always passes through untouched — it is never degraded
404
+ into feedback text, whatever ``on_failure`` says. A callable's ``None``
405
+ result is coerced to a fallback text (a ``None`` completion text would
406
+ crash the run later), and non-string returns are stringified.
407
+ """
408
+ max_retries = max(0, int(max_retries))
409
+
410
+ async def wrap_model(req: ModelRequest, next_fn: ModelHookNext, ctx: HookContext) -> ModelCompletion:
411
+ last: BaseException | None = None
412
+ for attempt in range(max_retries + 1):
413
+ try:
414
+ return await next_fn(req)
415
+ except Exception as err: # noqa: BLE001 - rethrown / synthesized below
416
+ last = err
417
+ if not _should_retry(err, retry_on) or attempt == max_retries:
418
+ break
419
+ await _sleep(_backoff_delay_ms(attempt, initial_delay_ms, backoff_factor, max_delay_ms, jitter))
420
+ # Control errors are never degraded to completion text: budget
421
+ # exhaustions, handoffs, and other AgentErrors (plus HITL control
422
+ # interrupts) must reach the caller regardless of on_failure.
423
+ if isinstance(last, (AgentError, HitlInterrupt)):
424
+ raise last
425
+ failure_text = f"Model call failed after retries: {_err_message(last)}"
426
+ if on_failure == "continue":
427
+ return ModelCompletion(text=failure_text, usage=TokenUsage())
428
+ if callable(on_failure):
429
+ produced = on_failure(last)
430
+ if produced is None: # a None completion text would crash later
431
+ produced = "Model call failed after retries"
432
+ return ModelCompletion(text=produced if isinstance(produced, str) else str(produced), usage=TokenUsage())
433
+ raise last
434
+
435
+ return Middleware("model-retry", wrap_model=wrap_model)
436
+
437
+
438
+ def tool_retry(
439
+ max_retries: int = 3,
440
+ initial_delay_ms: float = 1000,
441
+ backoff_factor: float = 2,
442
+ max_delay_ms: float = 60000,
443
+ jitter: bool = True,
444
+ retry_on: RetryOn = None,
445
+ on_failure: OnFailure = "error",
446
+ tools: Optional[list[str]] = None,
447
+ ) -> Any:
448
+ """aroundTool middleware retrying tool executions with the same backoff /
449
+ verdict / onFailure semantics as model_retry. AgentErrors honor their
450
+ ``retryable`` flag: fatal ones pass through untouched. At most
451
+ ``max_retries`` retries per call.
452
+
453
+ ``on_failure`` applies ONLY to genuine unexpected/transient failures.
454
+ ``AgentError`` and ``HitlInterrupt`` ALWAYS pass through untouched,
455
+ whatever ``on_failure`` says: control errors (SWARM_HANDOFF routing,
456
+ HITL suspension, BUDGET_EXCEEDED) must reach the caller — degrading them
457
+ into tool feedback text would break swarm handoffs and HITL resume. A
458
+ callable's ``None`` result is coerced to a fallback string (never a
459
+ None), and non-string returns are stringified.
460
+ """
461
+ max_retries = max(0, int(max_retries))
462
+ only = set(tools) if tools else None
463
+
464
+ async def around_tool(call: ToolCallRef, next_fn: ToolHookNext, ctx: HookContext) -> Any:
465
+ if only and call.name not in only:
466
+ return await next_fn()
467
+ last: BaseException | None = None
468
+ for attempt in range(max_retries + 1):
469
+ try:
470
+ return await next_fn()
471
+ except Exception as err: # noqa: BLE001 - rethrown / synthesized below
472
+ last = err
473
+ if not _should_retry(err, retry_on) or attempt == max_retries:
474
+ break
475
+ await _sleep(_backoff_delay_ms(attempt, initial_delay_ms, backoff_factor, max_delay_ms, jitter))
476
+ # Control errors are never degraded to feedback text: SWARM_HANDOFF,
477
+ # HitlInterrupt, and every other AgentError reach the caller as-is.
478
+ if isinstance(last, (AgentError, HitlInterrupt)):
479
+ raise last
480
+ if on_failure == "continue":
481
+ # Degrade gracefully: the error becomes tool feedback, the run finishes.
482
+ return f'Tool "{call.name}" failed after retries: {_err_message(last)}'
483
+ if callable(on_failure):
484
+ produced = on_failure(last)
485
+ if produced is None: # never a None content on the result
486
+ produced = "Tool failed after retries"
487
+ return produced if isinstance(produced, str) else str(produced)
488
+ raise last
489
+
490
+ return Middleware("tool-retry", around_tool=around_tool)
491
+
492
+
493
+ # ---------------------------------------------------------------------------
494
+ # Budgets
495
+ # ---------------------------------------------------------------------------
496
+
497
+
498
+ def model_call_limit(max_calls: int) -> Any:
499
+ """Cap model calls per run; exceeded -> AgentError(BUDGET_EXCEEDED).
500
+ Stateless (uses ctx.step)."""
501
+
502
+ async def before_model(req: ModelRequest, ctx: HookContext) -> Optional[ModelRequest]:
503
+ if ctx.step >= max_calls:
504
+ raise AgentError("BUDGET_EXCEEDED", f"Exceeded model call limit ({max_calls})", False)
505
+ return None
506
+
507
+ return Middleware("model-call-limit", before_model=before_model)
508
+
509
+
510
+ def tool_call_limit(
511
+ tool_name: Optional[str] = None,
512
+ run_limit: Optional[int] = None,
513
+ exit_behavior: str = "error",
514
+ ) -> Any:
515
+ """Per-run tool-call budgets (global or per-tool, counted at execution).
516
+ A call past ``run_limit`` is blocked with a ToolRejection (continue) or
517
+ raises AgentError(BUDGET_EXCEEDED) (error)."""
518
+ if run_limit is None:
519
+ raise ValueError("tool_call_limit: run_limit is required")
520
+ counts: dict[str, int] = {}
521
+
522
+ async def before_model(req: ModelRequest, ctx: HookContext) -> Optional[ModelRequest]:
523
+ if ctx.step == 0:
524
+ counts.clear() # counters are per run
525
+ return None
526
+
527
+ async def around_tool(call: ToolCallRef, next_fn: ToolHookNext, ctx: HookContext) -> Any:
528
+ if tool_name is not None and call.name != tool_name:
529
+ return await next_fn()
530
+ key = tool_name or "*"
531
+ used = counts.get(key, 0) + 1
532
+ counts[key] = used
533
+ if used > run_limit:
534
+ if exit_behavior == "error":
535
+ raise AgentError("BUDGET_EXCEEDED", f"Tool call limit exceeded for {tool_name or 'all tools'} ({run_limit})", False)
536
+ raise ToolRejection(
537
+ f'Tool "{call.name}" call limit exceeded ({run_limit}). Do not retry this tool; finish with what you have.'
538
+ )
539
+ return await next_fn()
540
+
541
+ return Middleware("tool-call-limit", before_model=before_model, around_tool=around_tool)
542
+
543
+
544
+ # ---------------------------------------------------------------------------
545
+ # Policy filter
546
+ # ---------------------------------------------------------------------------
547
+
548
+ _SAFE_RE_FLAGS = re.IGNORECASE | re.MULTILINE | re.DOTALL | re.UNICODE | re.ASCII | re.VERBOSE | re.LOCALE
549
+
550
+
551
+ def _sanitize_pattern(entry: Any) -> re.Pattern[str]:
552
+ """Clone a pattern so stateful/\"global-flag\" patterns (JS /g regexes
553
+ are stateful across uses) can never alternate deny/allow verdicts."""
554
+ if isinstance(entry, re.Pattern):
555
+ return re.compile(entry.pattern, entry.flags)
556
+ # Duck-typed pattern-like (the /g trap stands in for these): recompile
557
+ # from its source, keeping only the stateless re flags.
558
+ flags = getattr(entry, "flags", 0)
559
+ return re.compile(str(getattr(entry, "pattern", entry)), int(flags) & _SAFE_RE_FLAGS)
560
+
561
+
562
+ def _compile_rule(entry: Any) -> tuple[str, Any]:
563
+ """A rule is exact-string equality ('exact') or a sanitized regex ('regex')."""
564
+ if isinstance(entry, str):
565
+ return ("exact", entry)
566
+ return ("regex", _sanitize_pattern(entry))
567
+
568
+
569
+ def _rule_matches(name: str, rules: list[tuple[str, Any]]) -> bool:
570
+ for kind, rule in rules:
571
+ if kind == "exact":
572
+ if name == rule:
573
+ return True
574
+ elif rule.search(name) is not None:
575
+ return True
576
+ return False
577
+
578
+
579
+ def policy_filter(
580
+ allow: Optional[list[Any]] = None,
581
+ deny: Optional[list[Any]] = None,
582
+ exit_behavior: str = "continue",
583
+ ) -> Any:
584
+ """Allow/deny tool routing. Entries are exact tool-name strings or regexes
585
+ (compiled patterns are SANITIZED: recompiled without stateful behavior,
586
+ so a 'g-flagged' pattern cannot alternate deny/allow). Deny wins over
587
+ allow; a non-matching allow list denies. ``exit_behavior="continue"`` is
588
+ a ToolRejection (feedback, run continues); ``"error"`` raises AgentError."""
589
+
590
+ allow_rules = [_compile_rule(r) for r in (allow or [])]
591
+ deny_rules = [_compile_rule(r) for r in (deny or [])]
592
+
593
+ async def before_tool(call: ToolCallRef, ctx: HookContext) -> Optional[ToolCallRef]:
594
+ denied = _rule_matches(call.name, deny_rules) or (bool(allow_rules) and not _rule_matches(call.name, allow_rules))
595
+ if denied:
596
+ if exit_behavior == "error":
597
+ raise AgentError("POLICY_DENIED", f'Tool "{call.name}" is not allowed by policy', False)
598
+ raise ToolRejection(f'Tool "{call.name}" is blocked by policy. Choose a different tool or finish with what you have.')
599
+ return None
600
+
601
+ return Middleware("policy-filter", before_tool=before_tool)
602
+
603
+
604
+ # ---------------------------------------------------------------------------
605
+ # Todo list
606
+ # ---------------------------------------------------------------------------
607
+
608
+
609
+ class _WriteTodosArgs(BaseModel):
610
+ items: list[str]
611
+
612
+
613
+ def todo_list() -> Any:
614
+ """Task planning: contributes a ``write_todos`` tool and tracks state per
615
+ run. Exposes ``list_todos()`` -> list of {content, status} dicts with
616
+ status pending | active | completed (the first item is the active one)."""
617
+ state: dict[str, list[dict[str, str]]] = {"todos": []}
618
+
619
+ async def execute(args: Any, ctx: ToolContext | None) -> Any:
620
+ todos: list[dict[str, str]] = []
621
+ for index, content in enumerate(list(args.items)):
622
+ todos.append({"content": content, "status": "active" if index == 0 else "pending"})
623
+ state["todos"] = todos
624
+ open_count = sum(1 for t in todos if t["status"] != "completed")
625
+ lines = "\n".join(f'- [{"x" if t["status"] == "completed" else " "}] {t["content"]}' for t in todos)
626
+ return f"Todos updated ({open_count} open):\n{lines}" if todos else "Todos cleared."
627
+
628
+ async def before_model(req: ModelRequest, ctx: HookContext) -> Optional[ModelRequest]:
629
+ if ctx.step == 0:
630
+ state["todos"] = [] # storage is per run
631
+ return None
632
+
633
+ def list_todos() -> list[dict[str, str]]:
634
+ return [dict(t) for t in state["todos"]]
635
+
636
+ mw = Middleware(
637
+ "todo-list",
638
+ tools=[
639
+ define_tool(
640
+ name="write_todos",
641
+ description="Track multi-step progress. Set exactly one item active at a time; mark completed as you finish.",
642
+ schema=_WriteTodosArgs,
643
+ execute=execute,
644
+ )
645
+ ],
646
+ before_model=before_model,
647
+ )
648
+ mw.list_todos = list_todos # type: ignore[attr-defined]
649
+ return mw
650
+
651
+
652
+ # ---------------------------------------------------------------------------
653
+ # PII
654
+ # ---------------------------------------------------------------------------
655
+
656
+ PIIMatch = dict[str, Any] # {"text": str, "start": int, "end": int}
657
+
658
+ PII_BUILTIN_DETECTORS: dict[str, re.Pattern[str]] = {
659
+ "email": re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}"),
660
+ "credit_card": re.compile(r"\b(?:\d[ -]?){13,16}\b"),
661
+ "ip": re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b"),
662
+ }
663
+
664
+ PIIDetector = Union[str, re.Pattern[str], Callable[[str], list[Any]]]
665
+ PIIStrategy = str # "redact" | "mask" | "hash" | "block"
666
+
667
+
668
+ def _nth_find(content: str, needle: str, n: int) -> int:
669
+ """Start of the n-th (0-based) occurrence of needle, or -1."""
670
+ start = content.find(needle)
671
+ for _ in range(n):
672
+ if start < 0:
673
+ return -1
674
+ start = content.find(needle, start + 1)
675
+ return start
676
+
677
+
678
+ def _overlaps(spans: list[tuple[int, int]], start: int, end: int) -> bool:
679
+ """True if [start, end) intersects any already-accepted span."""
680
+ return any(s < end and start < e for s, e in spans)
681
+
682
+
683
+ def _find_matches(detector: PIIDetector, content: str) -> list[PIIMatch]:
684
+ """Matches as {text, start, end} (original order). A callable detector
685
+ returns matched substrings or (start, end) span tuples.
686
+
687
+ Duplicate returns are DEDUPED against real occurrences: a detector
688
+ reporting the same substring twice consumes first occurrence #0, then
689
+ occurrence #1 — never two matches projected onto the FIRST occurrence,
690
+ which would double-patch one span and leave the second real occurrence
691
+ un-redacted (a leak). More duplicates than occurrences are dropped, and
692
+ every accepted match sits on a distinct, non-overlapping span so the
693
+ reverse patching in _scrub can never corrupt."""
694
+ out: list[PIIMatch] = []
695
+ if callable(detector):
696
+ consumed: dict[str, int] = {} # substring -> occurrences already mapped
697
+ taken: list[tuple[int, int]] = []
698
+ for m in detector(content) or []:
699
+ start: int
700
+ end: int
701
+ if isinstance(m, str):
702
+ if not m:
703
+ continue
704
+ n = consumed.get(m, 0)
705
+ consumed[m] = n + 1
706
+ start = _nth_find(content, m, n)
707
+ if start < 0:
708
+ continue # more duplicates than real occurrences
709
+ end = start + len(m)
710
+ elif isinstance(m, (tuple, list)) and len(m) == 2:
711
+ try:
712
+ start, end = int(m[0]), int(m[1])
713
+ except (TypeError, ValueError):
714
+ continue
715
+ start = max(0, min(start, len(content)))
716
+ end = max(start, min(end, len(content)))
717
+ else:
718
+ continue
719
+ if end <= start or _overlaps(taken, start, end):
720
+ continue # span already claimed: never patch it twice
721
+ taken.append((start, end))
722
+ out.append({"start": start, "end": end, "text": content[start:end]})
723
+ return out
724
+ if isinstance(detector, str):
725
+ pattern = re.compile(detector)
726
+ else:
727
+ pattern = _sanitize_pattern(detector)
728
+ for m in pattern.finditer(content):
729
+ if m.group(0):
730
+ out.append({"text": m.group(0), "start": m.start(), "end": m.end()})
731
+ return out
732
+
733
+
734
+ def _scrub(content: str, detector: PIIDetector, strategy: PIIStrategy, pii_type: str) -> str:
735
+ # Belt-and-braces: every replacement must land on a DISTINCT span —
736
+ # reverse patching two matches onto one span corrupts the text and
737
+ # leaves real occurrences un-redacted.
738
+ unique: dict[tuple[int, int], PIIMatch] = {}
739
+ for m in _find_matches(detector, content):
740
+ unique[(m["start"], m["end"])] = m
741
+ matches = list(unique.values())
742
+ if not matches:
743
+ return content
744
+ if strategy == "block":
745
+ raise ToolRejection(f"Blocked {pii_type} in content: PII policy violation. Do not output {pii_type}; remove it and reply again.")
746
+ out = content
747
+ for m in sorted(matches, key=lambda m: m["start"], reverse=True):
748
+ out = out[: m["start"]] + _apply_strategy(m["text"], pii_type, strategy) + out[m["end"] :]
749
+ return out
750
+
751
+
752
+ def _apply_strategy(text: str, pii_type: str, strategy: PIIStrategy) -> str:
753
+ if strategy == "redact":
754
+ return f"[REDACTED_{pii_type.upper()}]"
755
+ if strategy == "mask":
756
+ return text[:1] + "." * max(0, len(text) - 1) # keeps first char + dots
757
+ if strategy == "hash":
758
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
759
+ raise ValueError(f"pii_redact: unknown strategy {strategy!r}") # pragma: no cover - validated earlier
760
+
761
+
762
+ def _scrub_blocks(content: list[Any], scrub: Callable[[str], str]) -> list[Any]:
763
+ from .types import parse_content_block
764
+
765
+ out: list[Any] = []
766
+ for block in content:
767
+ parsed: Any = block
768
+ if isinstance(block, dict) and block.get("type") in ("text", "reasoning"):
769
+ try:
770
+ parsed = parse_content_block(block)
771
+ except Exception: # pragma: no cover - malformed block: leave as-is
772
+ parsed = block
773
+ if isinstance(parsed, TextBlock):
774
+ out.append(parsed.model_copy(update={"text": scrub(parsed.text)}))
775
+ elif isinstance(parsed, ReasoningBlock):
776
+ out.append(parsed.model_copy(update={"reasoning": scrub(parsed.reasoning)}))
777
+ else:
778
+ out.append(block)
779
+ return out
780
+
781
+
782
+ def pii_redact(
783
+ pii_type: str,
784
+ strategy: PIIStrategy = "redact",
785
+ detector: Optional[PIIDetector] = None,
786
+ apply_to_output: bool = True,
787
+ apply_to_input: bool = False,
788
+ apply_to_tool_results: bool = False,
789
+ ) -> Any:
790
+ """PII detection with redact/mask/hash/block strategies.
791
+
792
+ - redact: ``[REDACTED_<TYPE>]`` (e.g. ``[REDACTED_EMAIL]``)
793
+ - mask: first char kept, remaining chars become dots
794
+ - hash: sha256 hexdigest[:16] of the match
795
+ - block: ToolRejection on an output match (the run stops; the caller
796
+ decides how to surface it)
797
+
798
+ Built-in detectors: email, credit_card, ip. Applied to model output
799
+ (after_model) and/or input (before_model), and — with
800
+ ``apply_to_tool_results=True`` — to string tool outputs (after_tool).
801
+ """
802
+ if strategy not in ("redact", "mask", "hash", "block"):
803
+ raise ValueError(f"pii_redact: unknown strategy {strategy!r}")
804
+ resolved: PIIDetector
805
+ if detector is None:
806
+ resolved = PII_BUILTIN_DETECTORS.get(pii_type, lambda _content: [])
807
+ else:
808
+ resolved = detector
809
+
810
+ def scrub(text: str) -> str:
811
+ return _scrub(text, resolved, strategy, pii_type)
812
+
813
+ async def after_model(res: ModelCompletion, req: ModelRequest, ctx: HookContext) -> Optional[ModelCompletion]:
814
+ if not apply_to_output:
815
+ return None
816
+ scrubbed = scrub(res.text)
817
+ if scrubbed == res.text:
818
+ return None
819
+ if strategy == "block":
820
+ raise ToolRejection(f"Blocked {pii_type} in model output. Do not output {pii_type}; remove it and reply again.")
821
+ return ModelCompletion(
822
+ text=scrubbed,
823
+ thinking=res.thinking,
824
+ tool_calls=res.tool_calls,
825
+ usage=res.usage,
826
+ finish_reason=res.finish_reason,
827
+ )
828
+
829
+ async def before_model(req: ModelRequest, ctx: HookContext) -> Optional[ModelRequest]:
830
+ if not apply_to_input:
831
+ return None
832
+ changed = False
833
+ messages: list[ChatMessage] = []
834
+ for m in req.messages:
835
+ if isinstance(m.content, str):
836
+ scrubbed = scrub(m.content)
837
+ changed = changed or scrubbed != m.content
838
+ messages.append(m.model_copy(update={"content": scrubbed}))
839
+ else:
840
+ messages.append(m.model_copy(update={"content": _scrub_blocks(m.content, scrub)}))
841
+ if not changed:
842
+ return None
843
+ return ModelRequest(messages=messages, tools=req.tools, response_format=req.response_format)
844
+
845
+ async def after_tool(call: ToolCallRef, output: Any, ctx: HookContext) -> Any:
846
+ if not apply_to_tool_results or not isinstance(output, str):
847
+ return None
848
+ return scrub(output) # "block" rejects; other strategies scrub the text
849
+
850
+ return Middleware(f"pii-{pii_type}", before_model=before_model, after_model=after_model, after_tool=after_tool)
851
+
852
+ # ---------------------------------------------------------------------------
853
+ # Model fallback
854
+ # ---------------------------------------------------------------------------
855
+
856
+
857
+ def model_fallback(*fallbacks: ModelProvider) -> Any:
858
+ """Fall back across providers when the primary model fails (first
859
+ success wins). On a primary failure each fallback gets the SAME request
860
+ in order; when every provider fails, the LAST error is re-raised."""
861
+
862
+ async def wrap_model(req: ModelRequest, next_fn: ModelHookNext, ctx: HookContext) -> ModelCompletion:
863
+ try:
864
+ return await next_fn(req)
865
+ except Exception as primary_err: # noqa: BLE001 - retried below / re-raised
866
+ last: BaseException = primary_err
867
+ for fb in fallbacks:
868
+ try:
869
+ return await fb.complete(
870
+ CompleteOptions(messages=req.messages, tools=req.tools, response_format=req.response_format)
871
+ )
872
+ except Exception as err: # noqa: BLE001 - re-raised when exhausted
873
+ last = err
874
+ raise last
875
+
876
+ return Middleware("model-fallback", wrap_model=wrap_model)
877
+
878
+
879
+ # ---------------------------------------------------------------------------
880
+ # Summarization
881
+ # ---------------------------------------------------------------------------
882
+
883
+
884
+ async def _summarize_with_model(model: ModelProvider, messages: list[ChatMessage]) -> str:
885
+ transcript = "\n".join(
886
+ f"[{m.role}] {m.content if isinstance(m.content, str) else text_of(m.content)}" for m in messages
887
+ )
888
+ completion = await model.complete(
889
+ CompleteOptions(
890
+ messages=[
891
+ ChatMessage(
892
+ role="user",
893
+ content=f"Summarize this conversation history concisely, preserving decisions, names, and open items:\n{transcript}",
894
+ )
895
+ ],
896
+ tools=[],
897
+ )
898
+ )
899
+ return completion.text
900
+
901
+
902
+ def summarization_middleware(
903
+ model: ModelProvider,
904
+ trigger_tokens: int,
905
+ keep_messages: int = 20,
906
+ summary_prefix: str = "Prior context summary",
907
+ summarize: Optional[Callable[[list[ChatMessage]], Any]] = None,
908
+ ) -> Any:
909
+ """Auto-summarize history when estimated tokens (conversation.history_tokens)
910
+ pass ``trigger_tokens``, keeping the newest ``keep_messages``. The old
911
+ head is summarized by ``model`` (or a custom ``summarize`` callable) and
912
+ replaced with an assistant message prefixed by ``summary_prefix``; the
913
+ caller's message list is never mutated in place. Text-oriented, like the
914
+ TS original."""
915
+ keep = max(1, int(keep_messages))
916
+
917
+ async def before_model(req: ModelRequest, ctx: HookContext) -> Optional[ModelRequest]:
918
+ total = history_tokens(req.messages)
919
+ if total < trigger_tokens:
920
+ return None
921
+ head = req.messages[: max(0, len(req.messages) - keep)]
922
+ if not head:
923
+ return None
924
+ if summarize is not None:
925
+ summary = await _maybe_await(summarize(head))
926
+ else:
927
+ summary = await _summarize_with_model(model, head)
928
+ return ModelRequest(
929
+ messages=[
930
+ req.messages[0],
931
+ ChatMessage(role="assistant", content=f"{summary_prefix}\n{summary}"),
932
+ *req.messages[-keep:],
933
+ ],
934
+ tools=req.tools,
935
+ response_format=req.response_format,
936
+ )
937
+
938
+ return Middleware("summarization", before_model=before_model)
939
+
940
+
941
+ # ---------------------------------------------------------------------------
942
+ # Context editing
943
+ # ---------------------------------------------------------------------------
944
+
945
+
946
+ class ClearToolUsesEdit:
947
+ """Strip old tool-call/tool-result PAIRS (matched by ``tool_call_id``)
948
+ from the history; the ``keep_recent`` newest pairs survive.
949
+
950
+ Pinned deviation: the TS ClearToolUsesEdit only replaces old tool
951
+ OUTPUTS with a placeholder — this port REMOVES the pair entirely.
952
+ Pinned messages (``ChatMessage.pinned``) always survive: pinning either
953
+ half of a pair protects both. ``older_than_steps`` gates the edit: when
954
+ set, stripping only activates once the run's step counter reaches it.
955
+ """
956
+
957
+ def __init__(self, older_than_steps: int | None = None, keep_recent: int = 0) -> None:
958
+ self.older_than_steps = older_than_steps
959
+ self.keep_recent = max(0, int(keep_recent))
960
+
961
+ def apply(self, messages: list[ChatMessage], step: int = 0) -> list[ChatMessage]:
962
+ if self.older_than_steps is not None and step < self.older_than_steps:
963
+ return messages
964
+ ordered_ids: list[str] = []
965
+ for m in messages:
966
+ if m.role == "tool" and m.tool_call_id and m.tool_call_id not in ordered_ids:
967
+ ordered_ids.append(m.tool_call_id)
968
+ protected: set[str] = set()
969
+ if self.keep_recent:
970
+ protected.update(ordered_ids[-self.keep_recent :])
971
+ for m in messages:
972
+ if not m.pinned:
973
+ continue
974
+ if m.role == "tool" and m.tool_call_id:
975
+ protected.add(m.tool_call_id) # pinned result keeps its call
976
+ elif m.role == "assistant" and isinstance(m.content, list):
977
+ try:
978
+ blocks = m.blocks()
979
+ except Exception: # pragma: no cover - malformed content: keep as-is
980
+ continue
981
+ protected.update(b.id for b in blocks if isinstance(b, ToolCallBlock)) # pinned call keeps its result
982
+ out: list[ChatMessage] = []
983
+ changed = False
984
+ for m in messages:
985
+ if m.role == "tool" and m.tool_call_id and m.tool_call_id not in protected:
986
+ changed = True # strip the result half
987
+ continue
988
+ if m.role == "assistant" and isinstance(m.content, list):
989
+ try:
990
+ blocks = m.blocks()
991
+ except Exception: # pragma: no cover - malformed content: keep as-is
992
+ out.append(m)
993
+ continue
994
+ kept = [b for b in blocks if not (isinstance(b, ToolCallBlock) and b.id not in protected)]
995
+ if len(kept) == len(blocks):
996
+ out.append(m)
997
+ continue
998
+ changed = True
999
+ if kept: # text-only remnant survives with its call blocks stripped
1000
+ out.append(m.model_copy(update={"content": [b.model_dump() for b in kept]}))
1001
+ continue # call-only message with every call stripped: drop it
1002
+ out.append(m)
1003
+ return out if changed else messages
1004
+
1005
+
1006
+ Edit = Any # duck-typed: apply(messages[, step]) -> list[ChatMessage] (may be awaitable)
1007
+
1008
+
1009
+ def _apply_edit(edit: Edit, messages: list[ChatMessage], step: int) -> Any:
1010
+ try:
1011
+ arity = len(inspect.signature(edit.apply).parameters)
1012
+ except (TypeError, ValueError): # pragma: no cover - exotic callables
1013
+ arity = 2
1014
+ return edit.apply(messages, step) if arity >= 2 else edit.apply(messages)
1015
+
1016
+
1017
+ def context_editing_middleware(edits: Optional[list[Edit]] = None) -> Any:
1018
+ """beforeModel middleware running history edits in order. Each edit maps
1019
+ the message list to a (possibly shorter) one; an unchanged list returns
1020
+ ``None`` so the request passes through untouched."""
1021
+
1022
+ resolved = edits if edits is not None else [ClearToolUsesEdit()]
1023
+
1024
+ async def before_model(req: ModelRequest, ctx: HookContext) -> Optional[ModelRequest]:
1025
+ messages = req.messages
1026
+ changed = False
1027
+ for edit in resolved:
1028
+ new = await _maybe_await(_apply_edit(edit, messages, ctx.step))
1029
+ if new is not None and new is not messages:
1030
+ messages = new
1031
+ changed = True
1032
+ if not changed:
1033
+ return None
1034
+ return ModelRequest(messages=messages, tools=req.tools, response_format=req.response_format)
1035
+
1036
+ return Middleware("context-editing", before_model=before_model)
1037
+
1038
+
1039
+ # ---------------------------------------------------------------------------
1040
+ # LLM tool selector
1041
+ # ---------------------------------------------------------------------------
1042
+
1043
+
1044
+ def llm_tool_selector_middleware(
1045
+ model: ModelProvider,
1046
+ max_tools: Optional[int] = None,
1047
+ always_include: Optional[list[str]] = None,
1048
+ system_prompt: Optional[str] = None,
1049
+ ) -> Any:
1050
+ """Narrow a wide tool list before the main model call: the selector
1051
+ model gets a numbered menu of names + descriptions plus the last user
1052
+ query and replies with a comma-separated subset. ``always_include`` names
1053
+ are forced in; any failure (selector model error, unparsable reply, no
1054
+ surviving names) degrades to the ORIGINAL unfiltered tool list."""
1055
+ forced = list(always_include or [])
1056
+
1057
+ async def before_model(req: ModelRequest, ctx: HookContext) -> Optional[ModelRequest]:
1058
+ if max_tools is not None and len(req.tools) <= max_tools and not forced:
1059
+ return None
1060
+ if not req.tools:
1061
+ return None
1062
+ last_user = next((m for m in reversed(req.messages) if m.role == "user"), None)
1063
+ menu = "\n".join(f"{i}. {t.name}: {t.description}" for i, t in enumerate(req.tools, start=1))
1064
+ try:
1065
+ completion = await model.complete(
1066
+ CompleteOptions(
1067
+ messages=[
1068
+ ChatMessage(
1069
+ role="system",
1070
+ content=system_prompt
1071
+ or "Pick relevant tools. Reply with ONLY a comma-separated list of tool names.",
1072
+ ),
1073
+ ChatMessage(
1074
+ role="user",
1075
+ content=f"Tools:\n{menu}\n\nQuery: {text_of(last_user.content) if last_user else ''}",
1076
+ ),
1077
+ ],
1078
+ tools=[],
1079
+ )
1080
+ )
1081
+ except Exception: # noqa: BLE001 - selector failure degrades to the original
1082
+ return None
1083
+ picked = {name.strip() for name in completion.text.split(",") if name.strip()}
1084
+ picked.update(forced)
1085
+ tools = [t for t in req.tools if t.name in picked]
1086
+ if max_tools is not None:
1087
+ tools = tools[:max_tools]
1088
+ if not tools:
1089
+ return None
1090
+ return ModelRequest(messages=req.messages, tools=tools, response_format=req.response_format)
1091
+
1092
+ return Middleware("llm-tool-selector", before_model=before_model)
1093
+
1094
+
1095
+ # ---------------------------------------------------------------------------
1096
+ # Tool emulator
1097
+ # ---------------------------------------------------------------------------
1098
+
1099
+
1100
+ def tool_emulator_middleware(model: ModelProvider, tools: Optional[list[str]] = None) -> Any:
1101
+ """Emulate tool execution with an LLM (testing/prototyping without side
1102
+ effects). Around the named tools — or all of them — the real execute is
1103
+ skipped and the model produces a plausible output string. Tool
1104
+ descriptions are recorded from before_model so the emulation prompt can
1105
+ carry them; unnamed tools pass through to real execution."""
1106
+ only = set(tools) if tools else None
1107
+ descriptions: dict[str, str] = {}
1108
+
1109
+ def before_model(req: ModelRequest, ctx: HookContext) -> None:
1110
+ for t in req.tools:
1111
+ descriptions[t.name] = t.description
1112
+
1113
+ async def around_tool(call: ToolCallRef, next_fn: ToolHookNext, ctx: HookContext) -> Any:
1114
+ if only is not None and call.name not in only:
1115
+ return await next_fn()
1116
+ description = descriptions.get(call.name, "")
1117
+ prompt = (
1118
+ f'Emulate tool "{call.name}"'
1119
+ + (f" ({description})" if description else "")
1120
+ + f" with arguments {json.dumps(call.args, default=str)}."
1121
+ + " Reply with ONLY the plausible tool output."
1122
+ )
1123
+ completion = await model.complete(CompleteOptions(messages=[ChatMessage(role="user", content=prompt)], tools=[]))
1124
+ return completion.text
1125
+
1126
+ return Middleware("tool-emulator", before_model=before_model, around_tool=around_tool)
1127
+
1128
+
1129
+ # ---------------------------------------------------------------------------
1130
+ # Filesystem tools
1131
+ # ---------------------------------------------------------------------------
1132
+
1133
+
1134
+ class _LsArgs(BaseModel):
1135
+ path: str = "."
1136
+
1137
+
1138
+ class _ReadFileArgs(BaseModel):
1139
+ path: str
1140
+
1141
+
1142
+ class _WriteFileArgs(BaseModel):
1143
+ path: str
1144
+ content: str
1145
+
1146
+
1147
+ class _EditFileArgs(BaseModel):
1148
+ path: str
1149
+ old_string: str
1150
+ new_string: str
1151
+
1152
+
1153
+ def _resolve_in(root: Path, rel: str) -> Optional[Path]:
1154
+ """Validate a caller-supplied path INSIDE root (resolve + relative_to);
1155
+ escapes and unreadable paths return None."""
1156
+ try:
1157
+ full = (root / rel).resolve()
1158
+ full.relative_to(root)
1159
+ return full
1160
+ except (OSError, ValueError):
1161
+ return None
1162
+
1163
+
1164
+ def filesystem_tools(root: str | Path) -> list[Tool]:
1165
+ """ls / read_file / write_file / edit_file scoped to ``root``. Escapes
1166
+ and edit misses are ERROR FEEDBACK STRINGS, not raises (the model sees
1167
+ the problem and retries); every file op runs via asyncio.to_thread so
1168
+ the loop is never blocked."""
1169
+ base = Path(root).resolve()
1170
+
1171
+ async def ls(args: Any, ctx: ToolContext | None) -> str:
1172
+ full = _resolve_in(base, args.path)
1173
+ if full is None:
1174
+ return f"Error: path escapes root: {args.path}"
1175
+ try:
1176
+ entries = await asyncio.to_thread(os.listdir, full)
1177
+ except OSError as err:
1178
+ return f"Error: cannot list {args.path}: {err}"
1179
+ return "\n".join(entries)
1180
+
1181
+ async def read_file(args: Any, ctx: ToolContext | None) -> str:
1182
+ full = _resolve_in(base, args.path)
1183
+ if full is None:
1184
+ return f"Error: path escapes root: {args.path}"
1185
+ try:
1186
+ return await asyncio.to_thread(full.read_text, "utf-8")
1187
+ except UnicodeDecodeError:
1188
+ # Decode failures are FEEDBACK (the TOOL contract), never a raised
1189
+ # exception — a raw escape would kill the run with TOOL_EXECUTION.
1190
+ return f"Error: cannot read {args.path}: not valid UTF-8"
1191
+ except (OSError, ValueError) as err:
1192
+ return f"Error: cannot read {args.path}: {err}"
1193
+
1194
+ async def write_file(args: Any, ctx: ToolContext | None) -> str:
1195
+ full = _resolve_in(base, args.path)
1196
+ if full is None:
1197
+ return f"Error: path escapes root: {args.path}"
1198
+
1199
+ def _write() -> None:
1200
+ full.parent.mkdir(parents=True, exist_ok=True)
1201
+ full.write_text(args.content, "utf-8")
1202
+
1203
+ try:
1204
+ await asyncio.to_thread(_write)
1205
+ except OSError as err:
1206
+ return f"Error: cannot write {args.path}: {err}"
1207
+ return f"wrote {args.path} ({len(args.content)} chars)"
1208
+
1209
+ async def edit_file(args: Any, ctx: ToolContext | None) -> str:
1210
+ full = _resolve_in(base, args.path)
1211
+ if full is None:
1212
+ return f"Error: path escapes root: {args.path}"
1213
+
1214
+ def _edit() -> bool | None:
1215
+ content = full.read_text("utf-8")
1216
+ if args.old_string not in content:
1217
+ return None
1218
+ full.write_text(content.replace(args.old_string, args.new_string, 1), "utf-8")
1219
+ return True
1220
+
1221
+ try:
1222
+ found = await asyncio.to_thread(_edit)
1223
+ except UnicodeDecodeError:
1224
+ # The file is binary/latin-1: feedback, never a raised escape.
1225
+ return f"Error: cannot edit {args.path}: not valid UTF-8"
1226
+ except (OSError, ValueError) as err:
1227
+ return f"Error: cannot edit {args.path}: {err}"
1228
+ if found is None:
1229
+ return f"Error: oldString not found in {args.path}"
1230
+ return f"edited {args.path}"
1231
+
1232
+ return [
1233
+ define_tool(
1234
+ name="ls",
1235
+ description="List files in a directory (relative to workspace root).",
1236
+ schema=_LsArgs,
1237
+ execute=ls,
1238
+ ),
1239
+ define_tool(name="read_file", description="Read a file's full text.", schema=_ReadFileArgs, execute=read_file),
1240
+ define_tool(
1241
+ name="write_file",
1242
+ description="Write (create or overwrite) a file.",
1243
+ schema=_WriteFileArgs,
1244
+ execute=write_file,
1245
+ ),
1246
+ define_tool(
1247
+ name="edit_file",
1248
+ description="Replace the first occurrence of oldString with newString in a file.",
1249
+ schema=_EditFileArgs,
1250
+ execute=edit_file,
1251
+ ),
1252
+ ]
1253
+
1254
+
1255
+ def filesystem_middleware(root: str | Path) -> Any:
1256
+ """Filesystem access middleware: contributes ls/read_file/write_file/
1257
+ edit_file scoped to ``root``."""
1258
+ return Middleware("filesystem", tools=filesystem_tools(root))
1259
+
1260
+
1261
+ # ---------------------------------------------------------------------------
1262
+ # Subagents (task tool)
1263
+ # ---------------------------------------------------------------------------
1264
+
1265
+
1266
+ class _TaskArgs(BaseModel):
1267
+ subagent: str
1268
+ input: str
1269
+
1270
+
1271
+ def create_sub_agent_middleware(model: ModelProvider, subagents: list[dict[str, Any]]) -> Any:
1272
+ """Delegate to named specialists through a ``task`` tool — isolates
1273
+ context so the supervisor stays clean. Each call builds the subagent's
1274
+ Agent FRESH (from the middleware's ``model`` param) and returns its run
1275
+ text; unknown names return model-visible feedback."""
1276
+ defs = list(subagents)
1277
+ names = "; ".join(f"{s.get('name')} ({s.get('description', '')})".strip() for s in defs)
1278
+
1279
+ async def execute(args: Any, ctx: ToolContext | None) -> str:
1280
+ from .agent import Agent, AgentOptions # lazy: agent.py imports this module
1281
+
1282
+ defn = next((s for s in defs if s.get("name") == args.subagent), None)
1283
+ if defn is None:
1284
+ return f"Error: unknown subagent: {args.subagent}. Available: {names or 'none'}"
1285
+ options = AgentOptions(
1286
+ model=model,
1287
+ name=str(defn.get("name")),
1288
+ description=str(defn.get("description", "")),
1289
+ tools=list(defn.get("tools") or []),
1290
+ )
1291
+ system_prompt = defn.get("system_prompt")
1292
+ if system_prompt:
1293
+ options.system_prompt = str(system_prompt)
1294
+ return (await Agent(options).run(args.input)).text
1295
+
1296
+ task = define_tool(
1297
+ name="task",
1298
+ description=f"Delegate to a specialist subagent. Available: {names}. Returns the subagent's final answer.",
1299
+ schema=_TaskArgs,
1300
+ execute=execute,
1301
+ )
1302
+ return Middleware("subagents", tools=[task])