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.
- framework_agents/__init__.py +345 -0
- framework_agents/a2a_client.py +360 -0
- framework_agents/a2a_server.py +495 -0
- framework_agents/agent.py +920 -0
- framework_agents/checkpoint.py +231 -0
- framework_agents/config.py +200 -0
- framework_agents/conversation.py +249 -0
- framework_agents/errors.py +47 -0
- framework_agents/evals.py +127 -0
- framework_agents/mcp_client.py +477 -0
- framework_agents/memory.py +103 -0
- framework_agents/memory_compaction.py +63 -0
- framework_agents/middleware.py +1302 -0
- framework_agents/models/__init__.py +100 -0
- framework_agents/models/anthropic.py +339 -0
- framework_agents/models/capabilities.py +45 -0
- framework_agents/models/ollama.py +47 -0
- framework_agents/models/openai_compatible.py +409 -0
- framework_agents/observability.py +84 -0
- framework_agents/ontology_review.py +321 -0
- framework_agents/orchestration.py +169 -0
- framework_agents/pricing.py +63 -0
- framework_agents/prompts.py +132 -0
- framework_agents/rag.py +123 -0
- framework_agents/remote_sparql.py +213 -0
- framework_agents/runtime.py +88 -0
- framework_agents/sandbox.py +327 -0
- framework_agents/semantic.py +1614 -0
- framework_agents/serve.py +436 -0
- framework_agents/sessions.py +249 -0
- framework_agents/skills.py +213 -0
- framework_agents/state.py +89 -0
- framework_agents/streaming.py +74 -0
- framework_agents/swarm.py +318 -0
- framework_agents/tools.py +114 -0
- framework_agents/tracing.py +131 -0
- framework_agents/types.py +262 -0
- framework_agents/workflows.py +130 -0
- framework_agents-0.1.0.dist-info/METADATA +118 -0
- framework_agents-0.1.0.dist-info/RECORD +41 -0
- 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])
|