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
3 changes: 3 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ make lint
uv run pytest
```

Run these commands from the repository root. The integration tests require a
running Docker daemon for PostgreSQL, Valkey, and NucliaDB fixtures.

If your change only affects one package, running the relevant package tests is
acceptable.

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ Framework guides:

- [Harness SDK](docs/harness-sdk.md): build asynchronous tool-loop agents with
streaming events, persistence, limits, feedback, memory, and sub-agents.
- [Procedural Graphs](docs/procedural-graphs.md): add advisory harness guidance
and run offline, validation-gated graph evolution.

Agent packages:

Expand Down
1 change: 1 addition & 0 deletions agents/mcp/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Repository = "https://github.com/nuclia/hyperforge"
Changelog = "https://github.com/nuclia/hyperforge/blob/main/agents/mcp/CHANGELOG.md"

[tool.pytest.ini_options]
addopts = "--import-mode=importlib"
asyncio_mode = "auto"

[tool.uv.build-backend]
Expand Down
Empty file removed agents/mcp/tests/__init__.py
Empty file.
17 changes: 17 additions & 0 deletions docs/harness-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ An `AgentHarness` run:
Calls to `run()` on one harness are serialized. A harness instance can therefore
be reused for multiple turns in the same conversation.

For opt-in graph-based advice before each model decision and offline graph
evolution, see [Procedural Graphs](procedural-graphs.md). Guidance does not require
a context agent or change tool permissions.

## Creating an Agent

Hyperforge includes `NucliaModelClient`, which connects the harness to Nuclia's
Expand Down Expand Up @@ -76,6 +80,9 @@ class ApplicationModelClient:
yield ModelDelta(text="Hello", input_tokens=10, output_tokens=1)
```

Custom adapters must preserve multiple system messages and message order,
including request-only procedural guidance appended to solver messages.

## Using Legacy Agents

The legacy engine can use the harness as its root orchestrator. Configure an
Expand Down Expand Up @@ -322,6 +329,16 @@ agents. Production applications should implement `HarnessStorageProtocol` with
durable conversation, event, and memory storage. `create_agent()` is a convenience
function that constructs and loads an agent in one call.

With procedural guidance enabled, reload restores root procedural steps only
with the same pinned graph version. Missing or different conversation graph
metadata, or a mismatched root procedural step, disables procedural runtime
(including `single_action`) for that harness instance and completes normal
conversation replay. Stored metadata and events are preserved; the procedural
trajectory is cleared. This is independent of `failure_policy`, which applies
only to guidance request failures. Matching conversations resume unchanged.
Start a new conversation to use a new graph, not to continue the existing one
unguided; see [Conversation Reload](procedural-graphs.md#conversation-reload).

Conversation metadata can be supplied under `conversation_metadata` in the
execution context:

Expand Down
402 changes: 402 additions & 0 deletions docs/procedural-graphs.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion hyperforge/src/hyperforge/alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
fileConfig(config.config_file_name, disable_existing_loggers=False)

current_dir = os.path.dirname(os.path.abspath(__file__))
migrations_dir = os.path.dirname(current_dir)
Expand Down
1 change: 1 addition & 0 deletions hyperforge/src/hyperforge/harness/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ async def __call__(self, memory: QuestionMemory, manager: Manager) -> None:
},
feedback_enabled=self.config.feedback_enabled,
usage_limits=self.config.usage_limits,
procedural_guidance=self.config.procedural_guidance,
)
history = await memory.get_chat_history()
harness.add_messages(
Expand Down
2 changes: 2 additions & 0 deletions hyperforge/src/hyperforge/harness/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from hyperforge.driver import DriverConfig
from hyperforge.harness_sdk import SYSTEM_PROMPT, ReasoningEffort, UsageLimits
from hyperforge.models import MemoryConfig, Rules
from hyperforge.procedural import ProceduralGuidanceConfig


class HarnessAgentConfig(BaseModel):
Expand All @@ -18,6 +19,7 @@ class HarnessAgentConfig(BaseModel):
disabled_core_tools: list[str] = Field(default_factory=list)
feedback_enabled: bool = False
usage_limits: UsageLimits = Field(default_factory=UsageLimits)
procedural_guidance: ProceduralGuidanceConfig | None = None
rules: Rules = Field(default_factory=Rules)
memory: MemoryConfig = Field(default_factory=MemoryConfig)

Expand Down
99 changes: 96 additions & 3 deletions hyperforge/src/hyperforge/harness_sdk/harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@

from pydantic import BaseModel

from hyperforge.procedural.guidance import (
ProceduralGuidance,
ProceduralGuidanceConfig,
ProcedureStep,
)

from .agents import published_agent_to_tools
from .clients import ModelClient, ReasoningEffort
from .context import format_context
Expand Down Expand Up @@ -297,6 +303,8 @@ def __init__(
spawn_depth: int = 0,
parent_agent_id: str | None = None,
event_queue_size: int = DEFAULT_EVENT_QUEUE_SIZE,
procedural_guidance: ProceduralGuidanceConfig | None = None,
guidance_client: ModelClient | None = None,
) -> None:
if event_queue_size <= 0:
raise ValueError("event_queue_size must be positive")
Expand Down Expand Up @@ -367,6 +375,21 @@ def __init__(
if len(self._tools) != len(self._external_tools):
raise ValueError("Tool names must be unique")
self._install_core_tools(self._config.feedback_enabled)
self._procedural = (
ProceduralGuidance(procedural_guidance) if procedural_guidance else None
)
self._guidance_client = guidance_client or model_client
self._failed_procedural_calls: set[str] = set()
if self._procedural:
self._procedural.config.graph.check_tools(self._tools)

@property
def procedural_guidance(self) -> ProceduralGuidanceConfig | None:
return self._procedural.config if self._procedural else None

@property
def procedural_trajectory(self) -> tuple[ProcedureStep, ...]:
return tuple(self._procedural.steps) if self._procedural else ()

@property
def children(self) -> dict[str, ChildAgent]:
Expand All @@ -378,6 +401,20 @@ def child_results(self) -> dict[str, DictOutput]:

async def load(self, *, create: bool = True) -> None:
conversation = await self.storage.get_conversation(self.conversation_id)
if (
conversation is not None
and self._procedural
and conversation.metadata.get("procedural_graph_version")
!= self._procedural.version
):
logger.warning(
"Disabling procedural guidance: conversation=%s expected_graph=%s stored_graph=%s",
self.conversation_id,
self._procedural.version,
conversation.metadata.get("procedural_graph_version"),
)
self._procedural = None
self._failed_procedural_calls.clear()
if conversation is None:
if not create:
raise ValueError("Conversation not found")
Expand All @@ -393,11 +430,27 @@ async def load(self, *, create: bool = True) -> None:
self.messages = []
self._active_lazy_tools.clear()
lazy_tool_names = {tool.name for tool in self._external_tools if tool.lazy_load}
if self._procedural:
self._procedural.steps.clear()
pending_tool_outputs: dict[str, HarnessMessage] = {}
async for event in self.storage.iter_events(self.conversation_id):
self._last_event_id = event.id
if event.parent_agent_id is not None:
continue
if event.type == HarnessEventType.PROCEDURAL_STEP and self._procedural:
step = ProcedureStep.model_validate(event.payload)
if step.graph_version != self._procedural.version:
logger.warning(
"Disabling procedural guidance: conversation=%s event=%s expected_graph=%s stored_graph=%s",
self.conversation_id,
event.id,
self._procedural.version,
step.graph_version,
)
self._procedural = None
self._failed_procedural_calls.clear()
else:
self._procedural.steps.append(step)
if event.type in {
HarnessEventType.MESSAGE_ADDED,
HarnessEventType.MESSAGES_ADDED,
Expand Down Expand Up @@ -449,7 +502,10 @@ async def load(self, *, create: bool = True) -> None:

def _persisted_metadata(self) -> dict[str, Any]:
metadata = self.execution_context.get("conversation_metadata", {})
return dict(metadata) if isinstance(metadata, Mapping) else {}
result = dict(metadata) if isinstance(metadata, Mapping) else {}
if self._procedural:
result["procedural_graph_version"] = self._procedural.version
return result

def add_messages(self, messages: Iterable[HarnessMessage | dict[str, Any]]) -> None:
self._pending_messages.extend(
Expand Down Expand Up @@ -689,13 +745,19 @@ async def llm(self, tools: Iterable[HarnessTool] | None = None) -> AgentResult:
self._check_limit("max_turns", self.usage.turns)
result = AgentResult(text="")
call_id = uuid.uuid4().hex
request_messages = self.messages
if self._procedural:
self._procedural.decision_id = call_id
request_messages = await self._procedural.messages(
self, self._guidance_client
)
history_head_event_id = self._last_event_id
await self.emit(
HarnessEventType.LLM_STARTED,
{
"call_id": call_id,
"history_head_event_id": history_head_event_id,
"message_count": len(self.messages),
"message_count": len(request_messages),
"model": self.model,
"reasoning_effort": self.reasoning_effort,
"tools": [tool.name for tool in selected],
Expand All @@ -709,7 +771,7 @@ async def llm(self, tools: Iterable[HarnessTool] | None = None) -> AgentResult:
async for delta in self.model_client.stream(
model=self.model,
reasoning_effort=self.reasoning_effort,
messages=self.messages,
messages=request_messages,
tools=selected,
execution_context=self.execution_context,
):
Expand Down Expand Up @@ -894,6 +956,8 @@ async def _run(self, prompt: str) -> str:
await self.load()
self._start_usage()
self._turn_id = uuid.uuid4().hex
if self._procedural:
self._procedural.query = prompt
await self.emit(HarnessEventType.TURN_STARTED, {"prompt": prompt})
latest_system = next(
(
Expand All @@ -910,13 +974,32 @@ async def _run(self, prompt: str) -> str:
await self._flush_pending_messages()
await self._append_message(HarnessMessage(role="user", content=prompt))
loop_state = TurnLoopState()
multi_action_retries = 0
try:
if self._interrupted.is_set():
await self.emit(HarnessEventType.TURN_INTERRUPTED, {})
return "interrupted"
while not self._interrupted.is_set():
await self._consume_inbox()
result = await self._with_time_limit(self.llm())
if (
self._procedural
and self._procedural.config.single_action
and len(result.tool_calls) > 1
):
multi_action_retries += 1
if multi_action_retries > MAX_EMPTY_RETRIES:
raise ValueError(
"Solver repeatedly requested multiple actions in single_action mode"
)
await self._append_message(
HarnessMessage(
role="user",
content="No calls were executed. Request exactly one tool action per decision, or answer when done.",
)
)
continue
multi_action_retries = 0
if result.tool_calls:
await self._with_time_limit(
self._execute_tool_calls(result.tool_calls, result.text)
Expand Down Expand Up @@ -1029,6 +1112,14 @@ async def _execute_tool_calls(
messages = await asyncio.gather(
*(self._execute_tool_call(call) for call in calls)
)
if self._procedural:
# gather preserves requested order, independently of completion order.
for call, message in zip(calls, messages, strict=True):
step = self._procedural.record(
call, message, failed=call.id in self._failed_procedural_calls
)
self._failed_procedural_calls.discard(call.id or "")
await self.emit(HarnessEventType.PROCEDURAL_STEP, step.model_dump())
await self._append_messages(
[
HarnessMessage(role="assistant", content=text, tool_calls=calls),
Expand Down Expand Up @@ -1084,6 +1175,8 @@ async def _execute_tool_call(self, call: HarnessToolCall) -> HarnessMessage:
},
)
result = {"error": str(exc)}
if self._procedural:
self._failed_procedural_calls.add(call.id)
await self.emit(
HarnessEventType.TOOL_FAILED,
{"call_id": call.id, "tool": call.name, "result": result},
Expand Down
2 changes: 2 additions & 0 deletions hyperforge/src/hyperforge/harness_sdk/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ class HarnessEventType(StrEnum):
LLM_STARTED = "llm.started"
LLM_COMPLETED = "llm.completed"
LLM_FAILED = "llm.failed"
PROCEDURAL_GUIDANCE = "procedural.guidance"
PROCEDURAL_STEP = "procedural.step"
WORKFLOW_MESSAGE = "workflow.message"
TURN_COMPLETED = "turn.completed"
TURN_INTERRUPTED = "turn.interrupted"
Expand Down
22 changes: 22 additions & 0 deletions hyperforge/src/hyperforge/procedural/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""Opt-in procedural guidance and offline, validation-gated graph evolution."""

from .graph import (
EdgeEndpoints,
GraphEdits,
ProceduralGraph,
ProcedureEdge,
ProcedureNode,
prepare_candidate,
)
from .guidance import ProceduralGuidanceConfig, ProcedureStep

__all__ = [
"EdgeEndpoints",
"GraphEdits",
"ProceduralGraph",
"ProceduralGuidanceConfig",
"ProcedureEdge",
"ProcedureNode",
"ProcedureStep",
"prepare_candidate",
]
Loading
Loading