Skip to content

Tools

A model that can call tools can look things up and change things. That is useful, and dangerous. In HarnessWeaver, no tool runs without a manifest (RULES S2), and no call runs without passing the governor: deterministic checks on the tool, its arguments, the policy, the data the run has seen, and rate limits, before the tool executes and its result is cleaned up.

This chapter adds a third task, orders, that looks orders up with a tool.

Write the tool

A tool is an ordinary function. Add helpdesk/tools.py:

helpdesk/tools.py
"""The support desk's tools. Their manifests are in tools/*.tool.yaml."""

ORDERS = {
    "A-1001": {"order_id": "A-1001", "status": "delivered", "total_usd": 250.0},
    "A-1002": {"order_id": "A-1002", "status": "shipped", "total_usd": 40.0},
}


async def lookup_order(order_id: str) -> dict[str, object]:
    """Look up an order's status and total by its id."""
    return ORDERS.get(order_id, {"order_id": order_id, "status": "unknown"})

Write its manifest

The manifest is the contract between the tool's owner, the security team and the governor: what the model is told, how the tool is reached, what it may touch. Add tools/lookup_order.tool.yaml:

tools/lookup_order.tool.yaml
apiVersion: harnessweaver/v1
kind: Tool
metadata: {name: lookup_order, version: 1.0.0, owner: orders-team}
spec:
  transport: native
  handler: "helpdesk.tools:lookup_order"
  description: Look up an order's status and total by its id.
  input_schema:
    type: object
    properties:
      order_id: {type: string, pattern: "^A-[0-9]{4}$"}
    required: [order_id]
  definition_digest: sha256:13e081247828f80f05a59db148e00a7baeba40e0cb79d28befe87c3fd372c3df
  side_effect: read
  risk: low
  timeout: 5s
  • transport: native calls handler, a Python function, in the app's process. http posts the arguments to a URL, and code runs code in a sandbox.
  • description and input_schema are what the model sees. The governor checks every call's arguments against the schema, so order_id must look like A-1234.
  • side_effect, risk, and later egress and data_access, describe what a call can do. Policy rules and the governor read them.
  • definition_digest pins the tool's name, description and schema. More on that below.

Tip

uv run harnessweaver add tool helpdesk.tools:lookup_order writes a first manifest from the function's signature and docstring. Review it and fill in what only you know: risk, egress, data access.

Give the task the tool

Point the spec at the manifests, and add the orders task:

harness.yaml (the tools section)
tools:
  manifests: ["tools/*.tool.yaml"]
harness.yaml (the orders task)
  orders:
    runner: direct
    prompt: orders@1
    output: str
    tools: [lookup_order]
    budget: { max_model_calls: 4, max_tool_calls: 3, max_usd: 0.02 }
prompts/orders@1.md
You help Acme's customers with their orders. Look an order up with the
lookup_order tool before you say anything about it, and answer in two
sentences or fewer.

The direct runner offers the task's tools to the model and runs a bounded loop: each call the model asks for is governed and executed, and its result goes back to the model, up to max_steps model calls (8 by default). A registered tool the task doesn't offer is refused and the run blocks with code tool_not_offered. There is no planning and no open-ended agent loop. max_tool_calls in the budget caps the calls too.

Run it

The offline model asks for the tool first, then answers once it has seen the result:

harness.offline.yaml
models:
  provider: scripted
  rules:
    - role: tool
      reply: { text: "Order A-1002 has shipped; it should reach you in a day or two." }
    - contains: "A-1002"
      reply: { tool_calls: [{ name: lookup_order, arguments: { order_id: A-1002 } }] }
    - contains: "Question:"
      reply: { text: "A refund takes up to five business days to reach your card [help-refunds]." }
  replies:
    - output:
        category: billing
        priority: 2
        summary: Charged twice for the Pro plan and wants one payment refunded.
main.py
import asyncio

import harnessweaver as hw
from harnessweaver.testing import render_trace


async def main() -> None:
    harness = hw.load("harness.yaml")
    result = await harness.run("orders", "Where is my order A-1002?", principal="ann")
    print(result.output)
    tools = [e for e in result.trace if e.hook and e.hook.value.startswith("tool.")]
    print(render_trace(tools))


if __name__ == "__main__":
    asyncio.run(main())
$ HARNESSWEAVER_ENV=offline uv run python main.py
Order A-1002 has shipped; it should reach you in a day or two.
   8 hook    tool.before       harness -> continue
   9 note    tool.before       harness.governor -> continue: allowed
  10 hook    tool.after        harness -> continue

The governor's verdict is in the trace (allowed). It never records the arguments' values, only the step, the code and the reason (RULES R9).

The governor

Every tool call passes the same checks, whoever makes it: a runner's tool loop, an agent framework's adapter, or your own s.tool(...) in a session.

Step Checks Blocks with
1. Registry The tool has a manifest, is enabled (for this tenant), and its pinned digest matches tool_unknown, manifest_required, tool_disabled, digest_mismatch
2. Schema The arguments match the manifest's input_schema schema_invalid
3. Policy The policy engine's rules, and what needs a person's approval policy_denied
4. Trifecta Untrusted content and private data in the run, plus a tool that sends data out trifecta
5. Limits The manifest's rate limits, per principal and per tenant rate_limited
6. Judge An optional judge, which may only tighten the decision judge_blocked
7. Approval A call that needs a person's approval waits for one (Approvals) approval_rejected
8. Execute Runs the tool through its transport, with its timeout A ToolError when the tool fails
9. Output Sanitizes, truncates to max_bytes, and labels the result untrusted -

Steps 1 to 7 run on the arguments as the tool.before middleware left them, so no middleware can rewrite arguments past the checks. The tool governor explains each step in depth: the lethal trifecta, rate limits, idempotency keys and sandboxed code.

Pin the definition

A tool whose description or schema changes silently is a classic attack: the model gets new instructions, or the schema accepts new arguments. So a manifest pins its definition with definition_digest, a SHA-256 of its name, description and input schema.

doctor warns about unpinned manifests, and tools pin writes the digests:

$ uv run harnessweaver tools pin harness.yaml
pinned  lookup_order: sha256:13e081247828f80f05a59db148e00a7baeba40e0cb79d28befe87c3fd372c3df

From then on, if anyone changes the tool's description or schema without pinning again, the manifest still loads, but every call to it is blocked with digest_mismatch, until someone reviews the change and runs tools pin again.

Calling a tool from your own steps

In a session, s.tool calls a tool through the same governor and returns its output:

order = await s.tool("lookup_order", order_id="A-1002")

Every manifest field is described in Tool manifests.

Recap

  • A manifest describes each tool: what the model sees, how it's reached, what it can touch. No manifest, no call.
  • The direct runner runs a bounded tool loop, and every call passes the nine-step governor.
  • definition_digest pins a tool's definition, and a changed, unpinned tool can't run.