|
14 | 14 | from hive.models.base import BaseProvider |
15 | 15 | from hive.models.registry import estimate_cost |
16 | 16 | from hive.runtime.approval import ApprovalDecision, ApprovalGate, AwaitingApprovalSignal |
| 17 | +from hive.runtime.guardrails import GuardrailAction, GuardrailPipeline, GuardrailStage |
17 | 18 | from hive.runtime.instructions import InstructionLike, Instructions |
18 | 19 | from hive.runtime.memory import ConversationMemory, PersistentMemory |
19 | 20 | from hive.runtime.persona import Persona |
@@ -79,13 +80,17 @@ def __init__( |
79 | 80 | on_text: Callable[[str], None] | None = None, |
80 | 81 | tool_timeout: float = 0.0, |
81 | 82 | approval_gate: ApprovalGate | None = None, |
| 83 | + guardrails: GuardrailPipeline | None = None, |
82 | 84 | ): |
83 | 85 | self.name = name |
84 | 86 | self._model = model |
85 | 87 | self._on_text = on_text |
86 | 88 | # Optional human-in-the-loop gate. When set, tools it flags are paused for |
87 | 89 | # approval instead of executing (see _execute_tool_calls). None = no gating. |
88 | 90 | self._approval_gate = approval_gate |
| 91 | + # Optional content guardrails. When set, the task input is checked before the |
| 92 | + # model runs (pre-hook) and the final output before it is returned (post-hook). |
| 93 | + self._guardrails = guardrails |
89 | 94 | # Per-tool wall-clock limit (seconds); 0 disables. A tool that exceeds it |
90 | 95 | # becomes a tool-error result so one hung tool can't stall the whole cycle. |
91 | 96 | self._tool_timeout = tool_timeout |
@@ -473,6 +478,29 @@ async def run(self, task: Task) -> TaskResult: |
473 | 478 | self._tokens_warned = False |
474 | 479 | t0 = time.time() |
475 | 480 |
|
| 481 | + # Pre-hook: inspect the task input before the model sees it. A blocking |
| 482 | + # guardrail (e.g. prompt injection) refuses the run; a redacting one rewrites |
| 483 | + # the instruction the model receives. |
| 484 | + if self._guardrails: |
| 485 | + finding = self._guardrails.run(task.instruction, GuardrailStage.INPUT) |
| 486 | + if finding.triggered: |
| 487 | + logger.warning( |
| 488 | + "Agent %r: input guardrail %s (%s)", |
| 489 | + self.name, |
| 490 | + finding.action.value, |
| 491 | + "; ".join(finding.reasons), |
| 492 | + ) |
| 493 | + if finding.blocked: |
| 494 | + return TaskResult( |
| 495 | + task_id=task.id, |
| 496 | + status=TaskStatus.FAILED, |
| 497 | + output="", |
| 498 | + error=f"blocked by guardrail: {'; '.join(finding.reasons)}", |
| 499 | + duration_seconds=time.time() - t0, |
| 500 | + ) |
| 501 | + if finding.action is GuardrailAction.REDACT: |
| 502 | + task = task.model_copy(update={"instruction": finding.text}) |
| 503 | + |
476 | 504 | tools = self.get_tools() |
477 | 505 | tool_map = {t.name: t for t in tools} |
478 | 506 | tool_schemas = [t.to_schema() for t in tools] if tools else None |
@@ -529,11 +557,33 @@ async def run(self, task: Task) -> TaskResult: |
529 | 557 | conversation.add(response) |
530 | 558 |
|
531 | 559 | if not response.tool_calls: |
532 | | - self._write_conversation_log(task.id, conversation.get_messages(), "completed") |
| 560 | + # Post-hook: inspect the final output before returning it. A blocking |
| 561 | + # guardrail withholds it; a redacting one masks matched spans (e.g. PII). |
| 562 | + output = response.content |
| 563 | + if self._guardrails: |
| 564 | + finding = self._guardrails.run(output, GuardrailStage.OUTPUT) |
| 565 | + if finding.triggered: |
| 566 | + logger.warning( |
| 567 | + "Agent %r: output guardrail %s (%s)", |
| 568 | + self.name, |
| 569 | + finding.action.value, |
| 570 | + "; ".join(finding.reasons), |
| 571 | + ) |
| 572 | + if finding.blocked: |
| 573 | + output = "[output withheld by guardrail]" |
| 574 | + elif finding.action is GuardrailAction.REDACT: |
| 575 | + output = finding.text |
| 576 | + # The raw assistant message is already in the conversation; replace it |
| 577 | + # with the sanitized output for the on-disk log too, so a redacting |
| 578 | + # guardrail doesn't leak the unredacted content into the JSON log file. |
| 579 | + log_messages = conversation.get_messages() |
| 580 | + if output != response.content: |
| 581 | + log_messages = [*log_messages[:-1], Message.assistant(output)] |
| 582 | + self._write_conversation_log(task.id, log_messages, "completed") |
533 | 583 | return TaskResult( |
534 | 584 | task_id=task.id, |
535 | 585 | status=TaskStatus.COMPLETED, |
536 | | - output=response.content, |
| 586 | + output=output, |
537 | 587 | steps_taken=steps, |
538 | 588 | tool_calls_made=tool_calls_total, |
539 | 589 | duration_seconds=time.time() - t0, |
|
0 commit comments