Skip to content

Commit e9d242b

Browse files
author
root
committed
feat(hooks): add Deny.abort to support hard-abort of agent loop
Guards can now return Deny(reason, abort=True) to immediately terminate the agent loop instead of just injecting a soft-denial tool result. BeforeIteration also gains guard support for early iteration blocking.
1 parent cb75f08 commit e9d242b

5 files changed

Lines changed: 272 additions & 71 deletions

File tree

docs/hook-plugin-guide.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,24 @@ class RateLimitHandler:
6262

6363
self._counts[session_id] = count + len(event.tool_calls)
6464
return None
65+
66+
67+
class BlocklistHandler:
68+
"""Abort the agent loop if a blocked tool is called."""
69+
70+
hook_events = [(BeforeExecuteTools, "guard")]
71+
72+
def __init__(self, blocked_tools: list[str] | None = None) -> None:
73+
self._blocked = set(blocked_tools or [])
74+
75+
async def __call__(self, event: BeforeExecuteTools):
76+
for tc in event.tool_calls:
77+
if tc.name in self._blocked:
78+
return Deny(
79+
f"Blocked tool '{tc.name}' — agent loop aborted",
80+
abort=True,
81+
)
82+
return None
6583
```
6684

6785
### 2. Register the Entry Point
@@ -127,7 +145,8 @@ async def handler(event: EventType) -> HookResult | Modified | Deny | None: ...
127145
|-------------|----------|--------|
128146
| `None` | Observe | No action needed; handler ran as a side-effect |
129147
| `Modified(data)` | Transform | Apply the returned data to the event (dict keys mapped to event fields) |
130-
| `Deny(reason)` | Guard | Block the operation — runner injects the reason as a tool result |
148+
| `Deny(reason)` | Guard (soft) | Block the operation — runner injects the reason as a tool result and continues the loop |
149+
| `Deny(reason, abort=True)` | Guard (hard) | Block the operation — runner terminates the agent loop immediately, `reason` becomes the final content |
131150

132151
### Declaring subscriptions
133152

@@ -149,7 +168,7 @@ v1 exposes six event types covering the agent iteration lifecycle:
149168

150169
| Event | Fields | Mode |
151170
|-------|--------|------|
152-
| `BeforeIteration` | `iteration`, `messages` | observe |
171+
| `BeforeIteration` | `iteration`, `messages` | guard, observe |
153172
| `OnStream` | `delta`, `iteration` | observe |
154173
| `OnStreamEnd` | `resuming`, `iteration` | observe |
155174
| `BeforeExecuteTools` | `iteration`, `tool_calls`, `response` | guard, observe |

nanobot/agent/runner.py

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,14 @@ async def run(self, spec: AgentRunSpec) -> AgentRunResult:
295295
messages_for_model = messages
296296
context = AgentHookContext(iteration=iteration, messages=messages)
297297
session.context = context
298-
await center.emit(BeforeIteration(iteration=iteration, messages=messages), session)
298+
bi_result = await center.emit(BeforeIteration(iteration=iteration, messages=messages), session)
299+
if isinstance(bi_result, Deny) and bi_result.abort:
300+
final_content = bi_result.reason
301+
stop_reason = "aborted"
302+
context.final_content = final_content
303+
context.stop_reason = stop_reason
304+
await center.emit(_make_after_iteration(context, iteration), session)
305+
break
299306
response = await self._request_model(spec, messages_for_model, center, session, context)
300307
raw_usage = self._usage_dict(response.usage)
301308
context.response = response
@@ -334,6 +341,13 @@ async def run(self, spec: AgentRunSpec) -> AgentRunResult:
334341

335342
result = await center.emit(BeforeExecuteTools(iteration=iteration, tool_calls=context.tool_calls, response=context.response), session)
336343
if isinstance(result, Deny):
344+
if result.abort:
345+
final_content = result.reason
346+
stop_reason = "aborted"
347+
context.final_content = final_content
348+
context.stop_reason = stop_reason
349+
await center.emit(_make_after_iteration(context, iteration), session)
350+
break
337351
for tc in tool_calls:
338352
messages.append({
339353
"role": "tool",
@@ -347,16 +361,7 @@ async def run(self, spec: AgentRunSpec) -> AgentRunResult:
347361
)
348362
empty_content_retries = 0
349363
length_recovery_count = 0
350-
await center.emit(AfterIteration(
351-
iteration=iteration,
352-
final_content=context.final_content,
353-
stop_reason=context.stop_reason,
354-
usage=dict(context.usage),
355-
tool_calls=list(context.tool_calls),
356-
tool_events=list(context.tool_events),
357-
tool_results=list(context.tool_results),
358-
error=context.error,
359-
), session)
364+
await center.emit(_make_after_iteration(context, iteration), session)
360365
continue
361366

362367
results, new_events, fatal_error = await self._execute_tools(

nanobot/hooks/protocols.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,17 @@ class Deny:
2424
``reason`` is a human-readable string explaining the denial.
2525
The caller (emit site) decides how to act on it — soft-deny
2626
(inject reason into the conversation) or hard-deny (abort the
27-
operation).
27+
agent loop entirely).
28+
29+
When ``abort`` is ``False`` (default), the operation is denied
30+
but the agent loop continues — the reason is injected as a tool
31+
result so the LLM can adapt. When ``abort`` is ``True``, the
32+
agent loop terminates immediately and ``reason`` becomes the
33+
final content returned to the caller.
2834
"""
2935

3036
reason: str
37+
abort: bool = False
3138

3239

3340
HookResult = Modified | Deny | None

0 commit comments

Comments
 (0)