Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
673d246
:sparkles: Start processing tool responses with nemo check
evaline-ju Feb 10, 2026
bc7172f
:wrench::art: Update line length and lint
evaline-ju Feb 10, 2026
5165423
:rewind: Revert port change
evaline-ju Feb 10, 2026
188e8ec
:sparkles: SSE format in tools
evaline-ju Feb 10, 2026
7cfad9e
:loud_sound: Update tool response error message and debug logs
evaline-ju Feb 11, 2026
67dccea
:goal_net: Buffer intermediate response chunks
evaline-ju Feb 11, 2026
0b3e4b1
:goal_net: Handle empty chunk cases with EOS
evaline-ju Feb 11, 2026
e33d36e
:white_check_mark: Add nemocheck plugin tool pre and tool post invoke…
evaline-ju Feb 11, 2026
f880715
:white_check_mark: Add initial server tests
evaline-ju Feb 11, 2026
eafe4c2
:recycle::white_check_mark: Tool response body buffering
evaline-ju Feb 12, 2026
6246685
:recycle: First pass nemocheck as internal and nemocheck_external as …
evaline-ju Feb 13, 2026
9d8eaa7
:truck::recycle: Move around deploy files and references
evaline-ju Feb 13, 2026
700a696
:art: Lint test
evaline-ju Feb 13, 2026
e0b45d7
:construction_worker: Update CI references
evaline-ju Feb 13, 2026
b229dbf
:truck::white_check_mark: Update moved nemo check tests
evaline-ju Feb 13, 2026
33b3565
:wrench: Add hooks to config
evaline-ju Feb 13, 2026
6ede34f
:wrench: Updates for deploying external plugin
evaline-ju Feb 13, 2026
580262f
:goal_net: Add error handling for empty config
evaline-ju Feb 13, 2026
aee2006
:fire: Minimal pyproject
evaline-ju Feb 13, 2026
0be33b9
:twisted_rightwards_arrows: Merge with plugin refactor
evaline-ju Feb 14, 2026
c6d83f2
:art: Lint plugin
evaline-ju Feb 14, 2026
b00c91c
:white_check_mark::fire: Remove old plugin with refactor and update t…
evaline-ju Feb 14, 2026
1354e0e
:loud_sound: Update plugin log
evaline-ju Feb 14, 2026
0f710aa
:white_check_mark: Update server tests
evaline-ju Feb 16, 2026
5eaa94c
:goal_net: Update immediate response for not continued processing
evaline-ju Feb 16, 2026
3fc2672
:recycle: Refactor common immediate response
evaline-ju Feb 16, 2026
b9bf557
:recycle::goal_net: Consistent error responses
evaline-ju Feb 20, 2026
8c17638
:twisted_rightwards_arrows: Merge with main
evaline-ju Feb 23, 2026
946237c
:recycle: Refactor post tool invoke processing
evaline-ju Feb 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ jobs:
git diff --stat
exit 1
}

# Plugin tests
- name: Install nemocheck plugin dependencies
working-directory: ./plugins/examples/nemocheck
run: |
Expand All @@ -50,3 +52,13 @@ jobs:
- name: Run nemocheck plugin tests
working-directory: ./plugins/examples/nemocheck
run: uv run pytest tests

# Server tests
- name: Install server test dependencies
run: |
echo "Installing pytest and dependencies for server tests..."
pip install pytest pytest-asyncio
- name: Run server unit tests
run: |
echo "Running server unit tests..."
uv run pytest tests
3 changes: 2 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ repos:
hooks:
# Run the linter.
- id: ruff
args: [ --fix ]
args: [ --fix, --line-length=80 ]
# Run the formatter.
- id: ruff-format
args: [ --line-length=80 ]
16 changes: 10 additions & 6 deletions plugins/examples/nemo/nemo_wrapper_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,20 +62,24 @@ async def tool_pre_invoke(
rails_response = await self._rails.generate_async(
messages=[{"role": "user", "content": payload_args}]
)
except (
asyncio.CancelledError
): # asyncio.exceptions.CancelledError is thrown by nemo, need to catch
logging.exception("An error occurred in the nemo plugin except block:")
except asyncio.CancelledError: # asyncio.exceptions.CancelledError is thrown by nemo, need to catch
logging.exception(
"An error occurred in the nemo plugin except block:"
)
finally:
logger.warning("[NemoWrapperPlugin] Async rails executed")
logger.warning(rails_response)
if rails_response and "PII detected" in rails_response["content"]:
logger.warning("[NemoWrapperPlugin] PII detected, stopping processing")
logger.warning(
"[NemoWrapperPlugin] PII detected, stopping processing"
)
return ToolPreInvokeResult(
modified_payload=payload, continue_processing=False
)
logger.warning("[NemoWrapperPlugin] No PII detected, continuing")
return ToolPreInvokeResult(modified_payload=payload, continue_processing=True)
return ToolPreInvokeResult(
modified_payload=payload, continue_processing=True
)

async def tool_post_invoke(
self, payload: ToolPostInvokePayload, context: PluginContext
Expand Down
124 changes: 109 additions & 15 deletions plugins/examples/nemocheck/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,9 @@ def __init__(self, config: PluginConfig):
)
else:
self.check_endpoint = DEFAULT_CHECK_ENDPOINT
logger.warning("Plugin config is empty or invalid, using default endpoint")
logger.warning(
"Plugin config is empty or invalid, using default endpoint"
)
logger.info(f"Nemo Check endpoint: {self.check_endpoint}")

async def prompt_pre_fetch(
Expand Down Expand Up @@ -105,9 +107,11 @@ async def tool_pre_invoke(
Returns:
The result of the plugin's analysis, including whether the tool can proceed.
"""
logger.info("tool_pre_invoke....")
logger.info(payload)
tool_name = payload.name # ("tool_name", None)
logger.info(
f"[NemoCheck] Starting tool pre invoke hook with payload {payload}"
)

tool_name = payload.name
check_nemo_payload = {
"model": MODEL_NAME,
"messages": [
Expand All @@ -119,7 +123,9 @@ async def tool_pre_invoke(
"type": "function",
"function": {
"name": tool_name,
"arguments": payload.args.get("tool_args", None),
"arguments": payload.args.get(
"tool_args", None
),
},
}
],
Expand All @@ -135,7 +141,7 @@ async def tool_pre_invoke(
if response.status_code == 200:
data = response.json()
status = data.get("status", "blocked")
logger.debug(f"rails reply: {data}")
logger.debug(f"[NemoCheck] Rails reply: {data}")

if status == "success":
metadata = data.get("rails_status")
Expand All @@ -147,7 +153,7 @@ async def tool_pre_invoke(
violation = PluginViolation(
reason=f"Check tool rails:{status}.",
description=json.dumps(data),
code=f"checkserver_http_status_code:{response.status_code}",
code="NEMO_RAILS_BLOCKED",
details=metadata,
)
return ToolPreInvokeResult(
Expand All @@ -158,23 +164,25 @@ async def tool_pre_invoke(
else:
violation = PluginViolation(
reason="Tool Check Unavailable",
description="Tool arguments check server returned error",
code=f"checkserver_http_status_code:{response.status_code}",
details={},
description=f"Tool arguments check server returned error. Status code: {response.status_code}, Response: {response.text}",
code="NEMO_SERVER_ERROR",
details={"status_code": response.status_code},
)
return ToolPreInvokeResult(
continue_processing=False, violation=violation
)

except Exception as e:
logger.error(f"Error calling Nemo Check endpoint: {e}")
logger.error(f"[NemoCheck] Error checking tool arguments: {e}")
violation = PluginViolation(
reason="Tool Check Error",
description=f"Failed to connect to check server: {str(e)}",
code="checkserver_connection_error",
details={},
code="NEMO_CONNECTION_ERROR",
details={"error": str(e)},
)
return ToolPreInvokeResult(
continue_processing=False, violation=violation
)
return ToolPreInvokeResult(continue_processing=False, violation=violation)

async def tool_post_invoke(
self, payload: ToolPostInvokePayload, context: PluginContext
Expand All @@ -188,4 +196,90 @@ async def tool_post_invoke(
Returns:
The result of the plugin's analysis, including whether the tool result should proceed.
"""
return ToolPostInvokeResult(continue_processing=True)
logger.info(
f"[NemoCheck] Starting tool post invoke hook with payload {payload}"
)

# Extract content from payload.result
# payload.result format: {'content': [{'type': 'text', 'text': 'Hello, bob!'}]}
result_content = payload.result.get("content", [])
tool_name = payload.name

if not result_content:
logger.warning(
"[NemoCheck] No content in tool result, skipping check"
)
return ToolPostInvokeResult(continue_processing=True)

# Extract text content from the content array
# TODO: what to do if there's actually multiple texts?
text_content = ""
for item in result_content:
if item.get("type") == "text":
text_content += item.get("text", "")

# Build NeMo check payload for tool response
check_nemo_payload = {
"model": MODEL_NAME, # ideally optional
"messages": [
{"role": "tool", "content": text_content, "name": tool_name}
],
}

logger.debug(
f"[NemoCheck] Payload for guardrail check: {check_nemo_payload}"
)

violation = None
try:
response = requests.post(
self.check_endpoint, headers=HEADERS, json=check_nemo_payload
)
if response.status_code == 200:
data = response.json()
status = data.get("status", "blocked")
logger.debug(f"[NemoCheck] Rails reply: {data}")

if status == "success":
metadata = data.get("rails_status")
result = ToolPostInvokeResult(
continue_processing=True, metadata=metadata
)
else: # blocked
metadata = data.get("rails_status")
violation = PluginViolation(
reason=f"Check tool rails:{status}.",
description=json.dumps(data),
code="NEMO_RAILS_BLOCKED",
details=metadata,
)
result = ToolPostInvokeResult(
continue_processing=False,
violation=violation,
metadata=metadata,
)
else:
violation = PluginViolation(
reason="Tool Check Unavailable",
description=f"Tool response check server returned error. Status code: {response.status_code}, Response: {response.text}",
code="NEMO_SERVER_ERROR",
details={"status_code": response.status_code},
)
result = ToolPostInvokeResult(
continue_processing=False, violation=violation
)

logger.info(f"[NemoCheck] Tool post invoke result: {result}")
return result

except Exception as e:
logger.error(f"[NemoCheck] Error checking tool response: {e}")
violation = PluginViolation(
reason="Tool Check Error",
description=f"Failed to connect to check server: {str(e)}",
code="NEMO_CONNECTION_ERROR",
details={"error": str(e)},
)
return ToolPostInvokeResult(
continue_processing=False, violation=violation
)
8 changes: 6 additions & 2 deletions plugins/examples/nemocheck/tests/test_all.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,13 @@ async def test_prompt_pre_hook(plugin_manager: PluginManager):
async def test_prompt_post_hook(plugin_manager: PluginManager):
"""Test prompt post hook across all registered plugins."""
# Customize payload for testing
message = Message(content=TextContent(type="text", text="prompt"), role=Role.USER)
message = Message(
content=TextContent(type="text", text="prompt"), role=Role.USER
)
prompt_result = PromptResult(messages=[message])
payload = PromptPosthookPayload(prompt_id="test_prompt", result=prompt_result)
payload = PromptPosthookPayload(
prompt_id="test_prompt", result=prompt_result
)
global_context = GlobalContext(request_id="1")
result, _ = await plugin_manager.invoke_hook(
PromptHookType.PROMPT_POST_FETCH, payload, global_context
Expand Down
Loading