Skip to content
Open
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
164 changes: 96 additions & 68 deletions docs/harness-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,70 +192,58 @@ handlers always receive a validated model instance; they never receive `None`.
Tools can attach typed context to their result by setting `context_type` and, if
needed, registering a schema and formatter with `register_context()`.

Tools are inherited by spawned sub-agents by default. Set
`inheritance=ToolInheritancePolicy.DO_NOT_INHERIT` on `@tool` or `HarnessTool`
when a tool must remain on the current agent. Scoped Code Mode tools use this
policy by default.
Tools, including Code Mode, are inherited by spawned sub-agents. Code Mode
resolves tools against the harness executing it, not a captured parent tool list.

## Scoped Code Mode
## Code Mode

Use `create_codemode_tool()` when generated Python should orchestrate a small,
explicit capability set without exposing those capabilities as top-level model
tools. The capability list must be an immutable tuple. Scoped Code Mode never
calls `harness.iter_tools()` and never includes core or external tools unless the
caller explicitly passes them.
Use `create_codemode_tool()` to orchestrate the agent's currently active external
tools with restricted Python. Application tools are available automatically;
inactive lazy tools and core agent tools (such as memory, tool discovery, and
agent spawning) are excluded. Use core agent tools directly, outside Code Mode.
Code Mode instances are also excluded to prevent recursive execution.

```python
from pydantic import BaseModel, Field
The Code Mode description includes tool names and compact return types, so
generated code knows which result keys it can use. Arguments are omitted because
they are already described by the tools themselves. For example:

```text
- def list_data_catalog(...) -> TypedDict[{"name": str, "data": dict[str, Any]}]
```

`TypedDict` describes a JSON object's keys; it is not a class to instantiate in
generated code. Signatures reflect JSON results, and `...` indicates omitted
arguments or abbreviated details. Newly activated lazy tools appear in the
description on the next model call. Tool descriptions and full JSON schemas are
not repeated inside the Code Mode description. Python builtins such as `len`,
`range`, and `sum` remain available.

Optional `capabilities` are additional trusted Python utility functions, not
`HarnessTool` objects. Both synchronous and asynchronous functions are supported;
generated code calls either as an ordinary function, without `await`. Any iterable
is accepted and captured when the Code Mode tool is created. Utilities are not
registered as top-level agent tools. Their Python signatures, rather than JSON
schemas or repeated tool descriptions, are included in the Code Mode description.

```python
from hyperforge.harness_sdk import (
AgentHarness,
CodeModeCapability,
CodeModeExecutionLimiter,
CodeModeLimits,
HarnessTool,
ToolCallContext,
create_codemode_tool,
tool,
)


class SearchInput(BaseModel):
query: str = Field(description="Read-only catalog search query")


class SearchOutput(BaseModel):
matches: list[dict[str, str]]
internal_cursor: str | None = None


@tool(description="Search the approved catalog without modifying it.")
async def search_catalog(
context: ToolCallContext,
input_value: SearchInput,
) -> SearchOutput:
return SearchOutput(
matches=[{"id": "item-1", "title": input_value.query}],
internal_cursor="do-not-expose",
)
def double_string(input_string: str) -> str:
return input_string * 2


def project_search_result(
capability: HarnessTool,
output: BaseModel,
) -> dict[str, object]:
del capability
validated = SearchOutput.model_validate(output)
return {"matches": validated.matches}
def times_two(input: int) -> int:
return input * 2


code_mode = create_codemode_tool(
capabilities=(
CodeModeCapability(
search_catalog,
result_adapter=project_search_result,
),
),
capabilities=[double_string, times_two],
limits=CodeModeLimits(
max_source_bytes=64 * 1024,
max_result_bytes=256 * 1024,
Expand All @@ -270,30 +258,31 @@ code_mode = create_codemode_tool(
agent = AgentHarness(
model="your-model",
model_client=model_client,
tools=[code_mode],
tools=[*application_tools, code_mode],
)
```

Only `code_mode` is registered with the harness in this example.
`search_catalog` is callable from generated code but is not advertised as a
top-level model tool. The Code Mode tool description includes each capability's
description and argument schema so the model can write valid calls. Nested
arguments still pass through `HarnessTool.execute()`, including JSON Schema,
Pydantic input, and output validation.

Every `CodeModeCapability` has a result adapter. The safe default returns the
tool's formatted model-facing context as a string. Prefer an application adapter,
as above, when generated code needs selected structured fields. Projected values
must contain only JSON worker values; the SDK rejects non-serializable values,
non-finite numbers, Pydantic models at any nesting depth, and the reserved
`__model__` transport key. Use
`raw_codemode_result_adapter` only after explicitly deciding that the complete
JSON-mode Pydantic output is safe for generated code. A raw adapter does not
bypass serialization or result-size validation.
No capabilities are required: `create_codemode_tool()` makes active external
tools usable. Tool calls pass through `HarnessTool.execute()`, preserving
JSON Schema and Pydantic input/output validation. Results are the tool's complete
JSON-mode output, not its formatted context. **Do not put private fields in tool
outputs** just because a context formatter hides them. Remove such fields in the
tool itself if generated code must not see them.

Utility calls use normal Python argument binding, including defaults, positional-
only, and keyword-only arguments. Type annotations document utilities; they are
not runtime validators. Utilities run on the host and must be trusted, bounded
application functions; the sandbox does not restrict their implementation.
Utility results must contain only JSON worker values. Non-serializable values,
non-finite numbers, nested Pydantic models, and the reserved `__model__` key are
rejected. Tool and utility results share the same serialization and size limits.

Capability names must be unique ASCII public, non-keyword Python identifiers and must not
conflict with worker names such as `codemode`, `output`, `save`, `question`,
`agent_id`, `dataclass`, `Chunk`, `Context`, `List`, `Any`, or `Dict`.
`agent_id`, `dataclass`, `Chunk`, `Context`, `List`, `Any`, `Dict`, `re`, or `math`.
Utility names must not collide with active tool names; collisions fail rather
than silently shadowing a tool. Function arguments named `agent_id` pass through
unchanged.

`CodeModeLimits` applies source, per-call projected-result, cumulative
projected-result, final-output, and nested-call limits to each invocation.
Expand Down Expand Up @@ -327,6 +316,40 @@ built-ins: `abs`, `bool`, `bytes`, `chr`, `complex`, `divmod`, `float`, `hash`,
`hex`, `id`, `int`, `isinstance`, `issubclass`, `len`, `oct`, `ord`, `pow`,
`range`, `repr`, `round`, `slice`, `sorted`, `str`, `sum`, `tuple`, and `zip`.
Other helpers, including `all`, `any`, `min`, and `max`, are unavailable.
`math`, `re`, `statistics`, `itertools`, and `decimal` are available without
imports. You can use their public functions, constants, and classes, as well as
public methods and properties on objects defined by these modules:

```python
amount = decimal.Decimal('0.1').quantize(decimal.Decimal('0.01'))
match = re.search('(x+)', 'xxx')
output({'amount': str(amount), 'matched': match.group(1)})
```

Imports, private attributes, and attributes on unapproved objects are unavailable.
Submodules are not automatically supported. Comprehensions and
generator expressions are supported.
Public methods on `list`, `dict`, `str`, and `tuple` are available, including
`list.append()`, `dict.get()`, `dict.items()`, `dict.update()`, and `str.upper()`.
You can also construct these types and assign or delete list/dictionary items:

```python
result = query_data(sql='SELECT status, COUNT(*) AS count FROM resources.workorder GROUP BY status')
counts = []
for row in result['data'].get('rows', []):
counts.append(row['count'])
summary = {}
summary['total'] = sum(counts)
output(summary)
```

Attribute assignment on modules, classes, and other objects is unavailable.
`str.format()` and `str.format_map()` remain unavailable; use string
concatenation or f-strings instead. Core agent tools remain excluded from Code Mode.

Convert `decimal.Decimal` results to strings (to preserve precision) or floats
before returning them through `output()`, which accepts JSON values rather than
Decimal objects.
Authorization, nested/global call limits, projected-result limits,
and invalid or repeated `output` attempts remain terminal even if generated code
catches the immediate callback error. An output attempt rejected by worker
Expand Down Expand Up @@ -367,9 +390,14 @@ contract and persists its raw `code` and `question` arguments. Applications must
not embed secrets in either field. The nested events described above are the
sanitized audit surface for capability calls and results.

The existing exported `codemode` tool remains available for compatibility. It
discovers registered tools and returns raw JSON-mode outputs. New applications
that require capability isolation should use `create_codemode_tool()`.
The exported `codemode` convenience tool uses the same implementation and has
no additional utilities. It retains automatic remote/local selection for
compatibility: remote when a socket is configured, otherwise isolated local
execution (equivalent to `remote_required=None`).
Use the factory's default `remote_required=True` for production fail-closed
remote execution. `CodeModeCapability`, result adapters, and
`ToolInheritancePolicy` have been removed; register tools on the agent and pass
plain Python functions as additional capabilities instead.

Remote and isolated-process Code Mode execution move values over the bounded
JSON worker protocol. Values crossing those worker boundaries must be JSON
Expand Down
19 changes: 19 additions & 0 deletions hyperforge/src/hyperforge/codemode/modules.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import decimal
import itertools
import math
import re
import statistics
from types import ModuleType

# Shared by the worker's globals/attribute guard and the harness tool description.
ALLOWED_GLOBAL_MODULES: dict[str, ModuleType] = {
"re": re,
"math": math,
"statistics": statistics,
"itertools": itertools,
"decimal": decimal,
}

# Allow useful JSON/container operations without exposing the builtins module.
SAFE_BUILTIN_TYPES = frozenset({list, dict, str, tuple})
WRITABLE_CONTAINER_TYPES = frozenset({list, dict})
39 changes: 38 additions & 1 deletion hyperforge/src/hyperforge/codemode/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
guarded_iter_unpack_sequence,
guarded_unpack_sequence,
safe_builtins,
safer_getattr_raise,
)

from hyperforge.definition import FunctionDefinition
Expand All @@ -38,6 +39,11 @@
decode_protocol_value,
encode_protocol_value,
)
from .modules import (
ALLOWED_GLOBAL_MODULES,
SAFE_BUILTIN_TYPES,
WRITABLE_CONTAINER_TYPES,
)

BLOCKED_EXCEPTIONS = {
"BaseException",
Expand All @@ -48,6 +54,27 @@
REDACTED_EXECUTION_ERROR = "Generated code execution failed"


def guarded_module_attribute(obj: Any, name: str) -> Any:
"""Allow safe public attributes on approved modules and container types."""
if name.startswith("_"):
raise AttributeError("Object attributes are unavailable")
if not any(obj is module for module in ALLOWED_GLOBAL_MODULES.values()):
obj_type = obj if isinstance(obj, type) else type(obj)
if (
obj_type not in SAFE_BUILTIN_TYPES
and obj_type.__module__ not in ALLOWED_GLOBAL_MODULES
):
raise AttributeError("Object attributes are unavailable")
return safer_getattr_raise(obj, name)


def guarded_container_write(obj: Any) -> Any:
"""Allow item assignment/deletion on plain lists and dictionaries only."""
if type(obj) not in WRITABLE_CONTAINER_TYPES:
raise TypeError("Only list and dict items may be modified")
return obj


def _set_memory_limit(max_memory_bytes: int | None) -> None:
if max_memory_bytes is None or sys.platform == "darwin":
return
Expand Down Expand Up @@ -118,6 +145,7 @@ def __init__(
):
self.pipe = pipe
self.functions_agent_id: Dict[str, List[str]] = {}
self._harness_functions = False
self.debug = debug
self.json_protocol = json_protocol
self._redact_errors = False
Expand All @@ -135,6 +163,7 @@ def _process_question_context_sync(
redact_errors: bool = False,
):
self._redact_errors = redact_errors
self._harness_functions = redact_errors and set(function_names) == {"harness"}
self._output_attempted = False
self._output_send_failed = False
try:
Expand All @@ -159,8 +188,11 @@ def _process_question_context_sync(
if name not in BLOCKED_EXCEPTIONS
},
"sum": sum,
**{value.__name__: value for value in SAFE_BUILTIN_TYPES},
},
"_getitem_": lambda obj, index: obj[index],
"_getattr_": guarded_module_attribute,
"_write_": guarded_container_write,
"_getiter_": iter,
"_inplacevar_": guarded_inplace,
"dataclass": dataclass,
Expand All @@ -173,6 +205,7 @@ def _process_question_context_sync(
"_unpack_sequence_": guarded_unpack_sequence,
"save": self.save,
"question": question,
**ALLOWED_GLOBAL_MODULES,
}
)
if self.debug:
Expand Down Expand Up @@ -240,7 +273,11 @@ def save(
def valid_functions_call(
self, function_name: str, *args: Any, **kwargs: Any
) -> Any:
agent_id = kwargs.pop("agent_id", None)
# Harness function arguments must pass through unchanged. Legacy published
# agent calls still use agent_id to select the callback destination.
agent_id = (
"harness" if self._harness_functions else kwargs.pop("agent_id", None)
)
if agent_id is not None:
if agent_id not in self.functions_agent_id.get(function_name, []):
raise ValueError(
Expand Down
11 changes: 1 addition & 10 deletions hyperforge/src/hyperforge/harness_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,20 +39,16 @@
HarnessToolCall,
)
from .storage import HarnessStorageProtocol, InMemoryHarnessStorage
from .tools import HarnessTool, ToolCallContext, ToolInheritancePolicy, tool
from .tools import HarnessTool, ToolCallContext, tool
from .tools.codemode import (
CodeModeCapability,
CodeModeDispatch,
CodeModeExecutionLimiter,
CodemodeInput,
CodeModeLimits,
CodemodeOutput,
CodeModeResultAdapter,
CodeModeRunner,
codemode,
context_codemode_result_adapter,
create_codemode_tool,
raw_codemode_result_adapter,
)
from .usage import HarnessUsage, UsageLimitExceeded, UsageLimits

Expand All @@ -69,11 +65,9 @@
"ChatCompletionToolCallDelta",
"ChatCompletionToolCallFunctionDelta",
"ChatCompletionUsage",
"CodeModeCapability",
"CodeModeDispatch",
"CodeModeExecutionLimiter",
"CodeModeLimits",
"CodeModeResultAdapter",
"CodeModeRunner",
"CodemodeInput",
"CodemodeOutput",
Expand All @@ -98,18 +92,15 @@
"NucliaChatCompletionsClient",
"NucliaChatCompletionsError",
"ReasoningEffort",
"ToolInheritancePolicy",
"UsageLimitExceeded",
"UsageLimits",
"context_schema",
"codemode",
"context_codemode_result_adapter",
"create_agent",
"create_codemode_tool",
"format_context",
"make_context",
"register_context",
"raw_codemode_result_adapter",
"tool",
"published_agent_to_tools",
"preload_published_agent_to_tools",
Expand Down
Loading
Loading