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:
"""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:
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: nativecallshandler, a Python function, in the app's process.httpposts the arguments to a URL, andcoderuns code in a sandbox.descriptionandinput_schemaare what the model sees. The governor checks every call's arguments against the schema, soorder_idmust look likeA-1234.side_effect,risk, and lateregressanddata_access, describe what a call can do. Policy rules and the governor read them.definition_digestpins 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:
orders:
runner: direct
prompt: orders@1
output: str
tools: [lookup_order]
budget: { max_model_calls: 4, max_tool_calls: 3, max_usd: 0.02 }
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:
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.
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:
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
directrunner runs a bounded tool loop, and every call passes the nine-step governor. definition_digestpins a tool's definition, and a changed, unpinned tool can't run.