Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
47 changes: 47 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
.git
.github
.env
.env.*
!.env.example
*.pem
*.key
*.p12
*.pfx
*.jks
*.keystore
*.creds
appsecrets
env.sh
storage-*.json

.venv
**/.venv
node_modules
**/node_modules
__pycache__
**/__pycache__
*.py[cod]
.pytest_cache
**/.pytest_cache
.mypy_cache
**/.mypy_cache
.ruff_cache
**/.ruff_cache
.benchmarks
.coverage
.coverage.*
htmlcov
dist
build
site
*.egg-info

**/tests
**/test
**/cassettes
.vscode
.idea
.DS_Store
*.log
*.bak
SECURITY_SCAN_REPORT.md
10 changes: 7 additions & 3 deletions .github/workflows/bd_sca_scanner.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@ on:
- main
paths:
- '**/*.lock'
- '**/package.json'
- '**/package-lock.json'
pull_request:
branches:
- main
paths:
- '**/*.lock'
- '**/package.json'
- '**/package-lock.json'
workflow_dispatch:

jobs:
Expand All @@ -30,9 +34,9 @@ jobs:
id: calculate-detect-args
run: |
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "DETECT_ARGS=--detect.excluded.directories=.bridge --detect.included.detector.types=uv --detect.uv.dependency.groups.excluded=dev --detect.timeout=600 --detect.excluded.detector.types=GIT --detect.blackduck.rapid.compare.mode=BOM_COMPARE_STRICT" >> $GITHUB_OUTPUT
echo "DETECT_ARGS=--detect.excluded.directories=.bridge --detect.included.detector.types=uv,npm --detect.uv.dependency.groups.excluded=dev --detect.timeout=600 --detect.excluded.detector.types=GIT --detect.blackduck.rapid.compare.mode=BOM_COMPARE_STRICT" >> $GITHUB_OUTPUT
else
echo "DETECT_ARGS=--detect.excluded.directories=.bridge --detect.included.detector.types=uv --detect.uv.dependency.groups.excluded=dev --detect.timeout=600 --detect.excluded.detector.types=GIT" >> $GITHUB_OUTPUT
echo "DETECT_ARGS=--detect.excluded.directories=.bridge --detect.included.detector.types=uv,npm --detect.uv.dependency.groups.excluded=dev --detect.timeout=600 --detect.excluded.detector.types=GIT" >> $GITHUB_OUTPUT
fi

- name: Run Black Duck SCA PR Scan
Expand All @@ -49,6 +53,6 @@ jobs:
blackducksca_scan_full: ${{ github.event_name == 'pull_request' && 'false' || 'true' }}
project_directory: '.'
blackducksca_scan_failure_severities: 'BLOCKER,CRITICAL,MAJOR'
detect_search_depth: 1
detect_search_depth: 3
detect_args: ${{ steps.calculate-detect-args.outputs.DETECT_ARGS }}
include_diagnostics: false
14 changes: 14 additions & 0 deletions .github/workflows/hyperforge_workflow.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,20 @@ jobs:
- name: Install package dependencies with uv
run: uv sync --group dev

- name: Set up Node.js
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: "22.22.0"
cache: npm
cache-dependency-path: hyperforge/frontend/package-lock.json

- name: Audit and build frontend
working-directory: hyperforge/frontend
run: |
npm ci
npm audit --audit-level=moderate
npm run build

- name: Lint
working-directory: hyperforge
run: make lint
Expand Down
48 changes: 34 additions & 14 deletions HYPERFORGE.Dockerfile
Original file line number Diff line number Diff line change
@@ -1,22 +1,42 @@
FROM python:3.14 AS build
FROM python:3.14.7-slim-trixie@sha256:83ff1d245a3d57d04152252d3ef9cb361494d0b3395abd65a5ebe91c401c8e83 AS build

RUN pip install uv && apt update -y && apt install -y npm && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
COPY --from=ghcr.io/astral-sh/uv:0.10.8@sha256:88234bc9e09c2b2f6d176a3daf411419eb0370d450a08129257410de9cfafd2a /uv /usr/local/bin/uv

# Install dependencies
RUN uv venv /app
ENV VIRTUAL_ENV=/app
COPY . /app/.
RUN uv sync --active --frozen --directory /app --compile-bytecode
RUN apt-get update \
&& apt-get install --no-install-recommends -y build-essential libpq-dev \
&& rm -rf /var/lib/apt/lists/*

#
# Only copy the virtual env to the final image.
#
FROM python:3.14
COPY --from=build /app /app
ENV PATH=/app/bin:$PATH
WORKDIR /src
COPY pyproject.toml uv.lock ./
COPY hyperforge/ hyperforge/
COPY agents/ agents/

RUN uv venv /opt/venv \
&& VIRTUAL_ENV=/opt/venv uv sync --active --frozen --no-dev --no-editable --compile-bytecode

FROM python:3.14.7-slim-trixie@sha256:83ff1d245a3d57d04152252d3ef9cb361494d0b3395abd65a5ebe91c401c8e83

RUN apt-get update \
&& apt-get install --no-install-recommends -y libpq5 \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --gid 10001 hyperforge \
&& useradd --uid 10001 --gid 10001 --create-home hyperforge

COPY --from=build --chown=10001:10001 /opt/venv /opt/venv

ENV PATH="/opt/venv/bin:${PATH}" \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1

WORKDIR /home/hyperforge
USER 10001:10001
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/ready', timeout=2)"]
CMD ["hyperforge-api"]
Comment thread
bloodbare marked this conversation as resolved.
Resolved

# OCI metadata: links this image to the GitHub repo as a package, with source + license.
LABEL org.opencontainers.image.source="https://github.com/nuclia/hyperforge" \
org.opencontainers.image.description="Agentic workflow on top of NUA" \
org.opencontainers.image.licenses="Apache-2.0" \
org.opencontainers.image.authors="Nuclia Team"
org.opencontainers.image.authors="Nuclia Team"
36 changes: 23 additions & 13 deletions agents/external/src/hyperforge_external/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@
from hyperforge.manager import Manager
from hyperforge.memory import QuestionMemory
from hyperforge.trace import trace_agent
from hyperforge.utils import check_dns
from hyperforge.utils.http import safe_http_client
from hyperforge.utils.http import read_limited_response, safe_http_client

from hyperforge_external.config import ExternalCallAgentConfig

Expand Down Expand Up @@ -61,9 +60,8 @@ async def __call__(
output_nuclia_tokens: float = 0.0

evaluation = None
async with safe_http_client() as client:
url = await check_dns(self.config.url)

response_content = b""
async with safe_http_client(timeout=self.config.timeout) as client:
if self.config.call_schema:
logger.debug(
f"Calling external API with schema: {self.config.call_schema}, description: {self.config.description}"
Expand All @@ -83,19 +81,19 @@ async def __call__(
output_nuclia_tokens += output_tokens
input_nuclia_tokens += input_tokens
logger.debug(f"Json to do the call: {evaluation}")
resp = await client.request(
request = client.build_request(
method=self.config.method.value,
url=url,
url=self.config.url,
json=evaluation,
headers=self.config.headers,
)
elif self.config.context:
logger.debug(
f"Calling external API with generated context: {memory.contexts}"
)
resp = await client.request(
request = client.build_request(
method=self.config.method.value,
url=url,
url=self.config.url,
json=memory.contexts,
headers=self.config.headers,
)
Expand All @@ -109,26 +107,38 @@ async def __call__(
evaluation = {}
evaluation["answer"] = memory.final_answer
evaluation["question"] = memory.original_question
resp = await client.request(
request = client.build_request(
method=self.config.method.value,
url=self.config.url,
json=evaluation,
headers=self.config.headers,
)
resp = await client.send(request, stream=True)
try:
response_content = await read_limited_response(
resp, self.config.max_response_bytes
)
finally:
await resp.aclose()
error_resp = None
if resp is None:
error_resp = "No response from external API"
elif resp.status_code != 200:
error_resp = f"Error calling external API: {resp.status_code} - {resp.content.decode()}"
error_resp = f"Error calling external API: {resp.status_code}"
if error_resp:
logger.error(error_resp)
raise Exception(error_resp)
logger.info(f"Response from external API: {resp.content.decode()}")
response_text = response_content.decode("utf-8", errors="replace")
logger.info(
"External API returned status %s and %s bytes",
resp.status_code,
len(response_content),
)
await memory.add_step(
step_module="external",
step_title=self.step_title(self.config.method.value),
step_value=self.config.url,
step_reason=resp.content.decode(),
step_reason=response_text,
timeit=time() - t0,
step_agent_path=f"/postprocess/{self.config.id if self.config.id else 'default'}",
input_nuclia_tokens=input_nuclia_tokens,
Expand Down
7 changes: 7 additions & 0 deletions agents/external/src/hyperforge_external/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ class ExternalCallAgentConfig(AgentConfig):
title="URL to call",
description="",
)
timeout: int = Field(default=30, ge=1, le=300)
max_response_bytes: int = Field(
default=1024 * 1024,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not necessary, but it'd be nice if 1024 * 1024 was a constant

ge=1,
le=10 * 1024 * 1024,
description="Maximum accepted HTTP response size in bytes",
)

@field_validator("url")
def validate_url(cls, v):
Expand Down
13 changes: 7 additions & 6 deletions agents/external/tests/test_external.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import os
from unittest.mock import AsyncMock, MagicMock
from unittest.mock import AsyncMock

import httpx
import pytest
from hyperforge.engine import main as arag_main
from hyperforge.minimal_fixtures import cassette_nua_key
Expand Down Expand Up @@ -55,12 +56,12 @@
@pytest.mark.vcr
@pytest.mark.asyncio
async def test_external(mocker):
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.content = b"oki doki"
request = httpx.Request("POST", "https://example.com/aaa")
mock_response = httpx.Response(200, content=b"oki doki", request=request)

mock_client = AsyncMock()
mock_client.request = AsyncMock(return_value=mock_response)
mock_client.build_request.return_value = request
mock_client.send = AsyncMock(return_value=mock_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)

Expand All @@ -87,4 +88,4 @@ async def test_external(mocker):
"partner" in question_memory.steps[-2].reason
or "Peugeot" in question_memory.steps[-2].reason
)
assert mock_client.request.called
assert mock_client.send.called
29 changes: 17 additions & 12 deletions agents/http/src/hyperforge_http/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@
from hyperforge.context.agent import ContextAgent
from hyperforge.manager import Manager
from hyperforge.memory import Chunk, Context, QuestionMemory
from hyperforge.utils import check_dns
from hyperforge.utils.http import safe_http_client
from hyperforge.utils.http import read_limited_response, safe_http_client

from hyperforge_http.config import HTTPStaticAgentConfig

Expand All @@ -33,24 +32,22 @@ async def _get_question_context(
t0 = time()
error = None

url = await check_dns(self.config.url)

async with safe_http_client() as client:
if self.config.method == "GET":
response = await client.get(
url,
request = client.build_request(
"GET",
self.config.url,
headers=self.config.headers,
timeout=self.config.timeout,
params={self.config.question_query_param: question}
if self.config.question_query_param
else None,
)

elif self.config.method == "POST":
response = await client.post(
url,
request = client.build_request(
"POST",
self.config.url,
headers=self.config.headers,
timeout=self.config.timeout,
params={self.config.question_query_param: question}
if self.config.question_query_param
else None,
Expand All @@ -61,11 +58,19 @@ async def _get_question_context(
else:
raise ValueError(f"Unsupported HTTP method: {self.config.method}")

response = await client.send(request, stream=True)
try:
response_content = await read_limited_response(
response, self.config.max_response_bytes
)
finally:
await response.aclose()

if response.status_code != 200:
error = f"HTTP request failed with status code {response.status_code}"
context_text = ""
else:
context_text = response.content.decode("utf-8")
context_text = response_content.decode("utf-8", errors="replace")

context = Context(
agent_id=self.config.id if self.config.id else "http",
Expand All @@ -88,7 +93,7 @@ async def _get_question_context(
step_module=self.config.module,
step_title=self.step_title("HTTP request"),
step_agent_path=f"/context/{self.config.id if self.config.id else 'default'}",
step_value=f" HTTP {self.config.method} to {url}",
step_value=f" HTTP {self.config.method} to {self.config.url}",
timeit=time() - t0,
input_nuclia_tokens=0,
output_nuclia_tokens=0,
Expand Down
8 changes: 7 additions & 1 deletion agents/http/src/hyperforge_http/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,13 @@ class HTTPStaticAgentConfig(ContextAgentConfig):
description="Optional POST field id to include the question in the request body",
)
timeout: int = Field(
default=10, description="Timeout for the HTTP request in seconds"
default=10, ge=1, le=300, description="Timeout for the HTTP request in seconds"
)
max_response_bytes: int = Field(
default=1024 * 1024,
ge=1,
le=10 * 1024 * 1024,
description="Maximum accepted HTTP response size in bytes",
)

@field_validator("url")
Expand Down
Loading
Loading