# Contract redline proposed by an agent loop

An agent reads a playbook and a supplier contract through four tools, proposes edits by quoting text, and WolfDocx writes them as native Word tracked changes; runs from a recorded tool-call replay without an API key, or live against the Anthropic API.

- Download: https://wolfsdk.com/examples/projects/docx-redline-agent.zip
- Pinned release: `wolfdocx==0.1.1`
- Run: `python3 -m venv .venv && . .venv/bin/activate && pip install --require-hashes -r requirements.txt && python run.py`
- Compare the printed JSON with `expected-output.json`.

This page lists every file in the zip. Binary sample documents are listed by size and SHA-256;
`python make_sample.py` writes them again.

## Files

### README.md

````markdown
# Contract redline proposed by an agent loop, written by WolfDocx

An agent reviews `supplier-agreement.docx` against `playbook.md` through four tools: `read_playbook`,
`list_paragraphs`, `propose_tracked_edit` and `finish`. The model never handles offsets or XML. It proposes an
edit by quoting the text to replace; `tools.py` refuses a quote that is missing, ambiguous or outside editable
text and returns the error to the model, which retries. WolfDocx then writes every accepted proposal as a native
Word tracked change (`w:del` and `w:ins` with author and date) to `out/supplier-agreement-redline.docx`, and accepts
the agent's revisions into `out/supplier-agreement-accepted.docx` to show the resulting text.

Pinned release: `wolfdocx==0.1.1`. No model SDK: live mode calls the Anthropic Messages API with the standard
library.

## Run it

Python 3.11 or newer. `requirements.txt` pins every package, including dependencies, with SHA-256 hashes;
`requirements.in` lists the direct ones.

```bash
python3 -m venv .venv
. .venv/bin/activate            # Windows: .venv\Scripts\activate
pip install --require-hashes -r requirements.txt
python run.py
```

`run.py` prints a JSON summary. `expected-output.json` holds the summary the pinned release produces, so you can
compare: `python run.py > got.json && diff got.json expected-output.json`. Outputs go to `out/`; the sample is
never modified.

### Replay and live mode

By default `run.py` replays `replay/tool-calls.json`, so it needs no API key and no network. The replay is
scripted: its assistant turns were written by hand in the Messages API tool-use format, not recorded from a
model. It includes one bad quote (`90 days notice` without the apostrophe) so the refusal and retry path runs.
For every turn the replay also stores the tool results the loop must send first; if the document, the playbook,
or the wolfdocx release changes what the tools return, the run stops at the first difference.

To run the same loop against a model:

```bash
export ANTHROPIC_API_KEY=...
python run.py --live --model MODEL                            # any Messages API model with tool use
python run.py --live --model MODEL --record replay/my-run.json  # save the run as a replay
python run.py --replay replay/my-run.json                     # replay it offline
```

A live run's proposals depend on the model, so its output will differ from `expected-output.json`.

## Files

- `run.py`: the agent loop, publishing and acceptance.
- `tools.py`: the tool schemas and the `ReviewSession` that binds quotes to source paragraphs.
- `model.py`: the replay client, the Anthropic client and the recorder.
- `playbook.md`: the Customer's positions.
- `replay/tool-calls.json`: the scripted replay.
- `supplier-agreement.docx`: the contract.
- `make_sample.py`: writes the contract with the standard library.

## Sample document

`supplier-agreement.docx` is a self-made contract with one paragraph per clause; four clauses conflict with the playbook and two comply. Run `python make_sample.py` to write it again from the script.
````

### expected-output.json

```json
{
  "accepted": "out/supplier-agreement-accepted.docx",
  "accepted_text": {
    "4": "2. Payment. The Customer will pay each undisputed invoice within 45 days of receipt.",
    "5": "3. Prices. The Supplier may change its prices on 60 days' written notice to the Customer.",
    "6": "4. Liability. The Supplier's total liability under this agreement is limited to the fees paid in the 12 months before the claim.",
    "7": "5. Term. This agreement renews automatically for successive one-year terms unless either party gives 30 days' notice before the renewal date."
  },
  "agent_summary": "Proposed four tracked changes: payment within 45 days, 60 days' written notice for price changes, a liability cap of 12 months' fees, and 30 days' notice to stop renewal. Confidentiality and governing law already meet the playbook.",
  "model": "replay:replay/tool-calls.json",
  "reasons": {
    "4": "Playbook 1: the Customer pays undisputed invoices within 45 days.",
    "5": "Playbook 2: price changes need 60 days' written notice.",
    "6": "Playbook 3: the cap is at least the fees paid in the 12 months before the claim.",
    "7": "Playbook 4: either party may stop renewal with 30 days' notice."
  },
  "redline": "out/supplier-agreement-redline.docx",
  "refused_proposals": [
    {
      "error": "find text does not occur in paragraph 7; quote it exactly as list_paragraphs shows it",
      "find": "90 days notice",
      "paragraph": 7
    }
  ],
  "tool_calls": {
    "finish": 1,
    "list_paragraphs": 1,
    "propose_tracked_edit": 5,
    "read_playbook": 1
  },
  "tracked_changes": [
    {
      "author": "Contract review agent",
      "date": "2026-11-13T12:00:00Z",
      "kind": "del",
      "paragraph": 4,
      "text": "15 days"
    },
    {
      "author": "Contract review agent",
      "date": "2026-11-13T12:00:00Z",
      "kind": "ins",
      "paragraph": 4,
      "text": "45 days"
    },
    {
      "author": "Contract review agent",
      "date": "2026-11-13T12:00:00Z",
      "kind": "del",
      "paragraph": 5,
      "text": "at any time"
    },
    {
      "author": "Contract review agent",
      "date": "2026-11-13T12:00:00Z",
      "kind": "ins",
      "paragraph": 5,
      "text": "on 60 days' written notice to the Customer"
    },
    {
      "author": "Contract review agent",
      "date": "2026-11-13T12:00:00Z",
      "kind": "del",
      "paragraph": 6,
      "text": "the month before the claim"
    },
    {
      "author": "Contract review agent",
      "date": "2026-11-13T12:00:00Z",
      "kind": "ins",
      "paragraph": 6,
      "text": "the 12 months before the claim"
    },
    {
      "author": "Contract review agent",
      "date": "2026-11-13T12:00:00Z",
      "kind": "del",
      "paragraph": 7,
      "text": "90 days'"
    },
    {
      "author": "Contract review agent",
      "date": "2026-11-13T12:00:00Z",
      "kind": "ins",
      "paragraph": 7,
      "text": "30 days'"
    }
  ],
  "turns": 5,
  "unchanged_paragraphs": 5
}
```

### make_sample.py

```python
"""Write supplier-agreement.docx, the self-made contract the agent reviews.

Standard library only: a minimal WordprocessingML package with one paragraph
per clause.
"""

from __future__ import annotations

import zipfile
from pathlib import Path
from xml.sax.saxutils import escape

HERE = Path(__file__).resolve().parent
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
STAMP = (2026, 1, 1, 0, 0, 0)
CLAUSES = [
    ("Supplier services agreement", "Title"),
    (
        "This agreement is between Harbor Lane Bakery Limited (the Customer) and Copperline Packaging Co. "
        "(the Supplier).",
        None,
    ),
    (
        "1. Services. The Supplier will design and print the packaging described in each purchase order.",
        None,
    ),
    ("2. Payment. The Customer will pay each undisputed invoice within 15 days of receipt.", None),
    ("3. Prices. The Supplier may change its prices at any time.", None),
    (
        "4. Liability. The Supplier's total liability under this agreement is limited to the fees paid in the "
        "month before the claim.",
        None,
    ),
    (
        "5. Term. This agreement renews automatically for successive one-year terms unless either party gives "
        "90 days' notice before the renewal date.",
        None,
    ),
    (
        "6. Confidentiality. Each party will keep the other party's confidential information secret for five "
        "years after this agreement ends.",
        None,
    ),
    ("7. Governing law. The laws of England and Wales govern this agreement.", None),
]


def paragraph(text: str, style: str | None) -> str:
    properties = f'<w:pPr><w:pStyle w:val="{style}"/></w:pPr>' if style else ""
    return f'<w:p>{properties}<w:r><w:t xml:space="preserve">{escape(text)}</w:t></w:r></w:p>'


def main() -> None:
    body = "".join(paragraph(text, style) for text, style in CLAUSES)
    parts = {
        "[Content_Types].xml": (
            '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
            '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
            '<Default Extension="xml" ContentType="application/xml"/>'
            '<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>'
            '<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>'
            "</Types>"
        ),
        "_rels/.rels": (
            '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
            '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>'
            "</Relationships>"
        ),
        "word/_rels/document.xml.rels": (
            '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
            '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>'
            "</Relationships>"
        ),
        "word/styles.xml": (
            f'<w:styles xmlns:w="{W}">'
            '<w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:ascii="Calibri" w:hAnsi="Calibri"/>'
            '<w:sz w:val="22"/></w:rPr></w:rPrDefault>'
            '<w:pPrDefault><w:pPr><w:spacing w:after="160"/></w:pPr></w:pPrDefault></w:docDefaults>'
            '<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>'
            '<w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:basedOn w:val="Normal"/>'
            '<w:rPr><w:b/><w:sz w:val="36"/></w:rPr></w:style>'
            "</w:styles>"
        ),
        "word/document.xml": (
            f'<w:document xmlns:w="{W}"><w:body>{body}'
            '<w:sectPr><w:pgSz w:w="12240" w:h="15840"/>'
            '<w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/>'
            "</w:sectPr></w:body></w:document>"
        ),
    }
    with zipfile.ZipFile(HERE / "supplier-agreement.docx", "w") as archive:
        for name, xml in parts.items():
            info = zipfile.ZipInfo(name, STAMP)
            info.compress_type = zipfile.ZIP_DEFLATED
            archive.writestr(
                info, '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' + xml
            )


if __name__ == "__main__":
    main()
```

### model.py

```python
"""Model clients for the review loop: a recorded replay and the Anthropic Messages API.

Both return one assistant message as a dict in the Messages API shape:
{"role": "assistant", "content": [text and tool_use blocks], "stop_reason": ...}.

The replay file stores, for every turn, the tool results the loop must send
before that turn and the assistant message to return. A replay whose tool
results differ from what the loop actually computed stops with the first
difference, so a changed document or a changed wolfdocx release cannot pass
silently.
"""

from __future__ import annotations

import json
import urllib.request
from pathlib import Path
from typing import Any

API_URL = "https://api.anthropic.com/v1/messages"
API_VERSION = "2023-06-01"


def tool_results(message: dict[str, Any]) -> list[dict[str, Any]] | None:
    """The tool results a user message carries, with their JSON content parsed."""
    if message["role"] != "user" or not isinstance(message["content"], list):
        return None
    return [
        {"tool_use_id": block["tool_use_id"], "result": json.loads(block["content"])}
        for block in message["content"]
        if block["type"] == "tool_result"
    ]


class ReplayMismatch(SystemExit):
    pass


class ReplayModel:
    def __init__(self, path: Path) -> None:
        self.path = path
        self.recording = json.loads(path.read_text(encoding="utf-8"))
        self.turns = self.recording["turns"]
        self.position = 0

    def create(
        self, *, system: str, tools: list[dict[str, Any]], messages: list[dict[str, Any]]
    ) -> dict[str, Any]:
        if self.position >= len(self.turns):
            raise ReplayMismatch(
                f"{self.path.name}: the loop asked for turn {self.position + 1}; the replay has {len(self.turns)}"
            )
        turn = self.turns[self.position]
        sent = tool_results(messages[-1])
        if sent != turn["expect_tool_results"]:
            raise ReplayMismatch(
                f"{self.path.name}: turn {self.position + 1} expected tool results\n"
                f"{json.dumps(turn['expect_tool_results'], indent=2)}\nbut the loop sent\n{json.dumps(sent, indent=2)}"
            )
        self.position += 1
        return turn["response"]


class AnthropicModel:
    """Live mode. Reads the key from ANTHROPIC_API_KEY; uses only the standard library."""

    def __init__(self, model: str, api_key: str, max_tokens: int = 2048) -> None:
        self.model = model
        self.api_key = api_key
        self.max_tokens = max_tokens

    def create(
        self, *, system: str, tools: list[dict[str, Any]], messages: list[dict[str, Any]]
    ) -> dict[str, Any]:
        body = {
            "model": self.model,
            "max_tokens": self.max_tokens,
            "system": system,
            "tools": tools,
            "messages": messages,
        }
        request = urllib.request.Request(
            API_URL,
            data=json.dumps(body).encode("utf-8"),
            headers={
                "x-api-key": self.api_key,
                "anthropic-version": API_VERSION,
                "content-type": "application/json",
            },
        )
        with urllib.request.urlopen(request, timeout=120) as response:
            reply = json.load(response)
        return {
            "role": "assistant",
            "content": reply["content"],
            "stop_reason": reply["stop_reason"],
        }


class Recorder:
    """Wrap a model and keep every turn in the replay format."""

    def __init__(self, model: Any) -> None:
        self.model = model
        self.turns: list[dict[str, Any]] = []

    def create(
        self, *, system: str, tools: list[dict[str, Any]], messages: list[dict[str, Any]]
    ) -> dict[str, Any]:
        response = self.model.create(system=system, tools=tools, messages=messages)
        self.turns.append({"expect_tool_results": tool_results(messages[-1]), "response": response})
        return response

    def save(self, path: Path, *, model: str, review_date: str) -> None:
        path.parent.mkdir(parents=True, exist_ok=True)
        recording = {
            "format": "anthropic-messages-tool-use/1",
            "model": model,
            "review_date": review_date,
            "turns": self.turns,
        }
        path.write_text(
            json.dumps(recording, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
        )
```

### playbook.md

```markdown
# Customer contract playbook: supplier agreements

Review supplier paper on behalf of the Customer. Propose a tracked change only
where a clause conflicts with a position below; leave every other clause alone.

1. Payment: the Customer pays undisputed invoices within 45 days of receipt.
2. Prices: the Supplier may change prices only on 60 days' written notice.
3. Liability: the Supplier's liability cap is at least the fees paid in the
   12 months before the claim.
4. Renewal: either party may stop an automatic renewal with 30 days' notice.
5. Confidentiality: at least three years after the agreement ends.
6. Governing law: England and Wales is acceptable.
```

### replay/tool-calls.json

```json
{
  "format": "anthropic-messages-tool-use/1",
  "model": "scripted",
  "review_date": "2026-11-13T12:00:00Z",
  "turns": [
    {
      "expect_tool_results": null,
      "response": {
        "role": "assistant",
        "content": [
          {
            "type": "text",
            "text": "I'll read the playbook and the contract first."
          },
          {
            "type": "tool_use",
            "id": "toolu_01",
            "name": "read_playbook",
            "input": {}
          },
          {
            "type": "tool_use",
            "id": "toolu_02",
            "name": "list_paragraphs",
            "input": {}
          }
        ],
        "stop_reason": "tool_use"
      }
    },
    {
      "expect_tool_results": [
        {
          "tool_use_id": "toolu_01",
          "result": {
            "playbook": "# Customer contract playbook: supplier agreements\n\nReview supplier paper on behalf of the Customer. Propose a tracked change only\nwhere a clause conflicts with a position below; leave every other clause alone.\n\n1. Payment: the Customer pays undisputed invoices within 45 days of receipt.\n2. Prices: the Supplier may change prices only on 60 days' written notice.\n3. Liability: the Supplier's liability cap is at least the fees paid in the\n   12 months before the claim.\n4. Renewal: either party may stop an automatic renewal with 30 days' notice.\n5. Confidentiality: at least three years after the agreement ends.\n6. Governing law: England and Wales is acceptable.\n"
          }
        },
        {
          "tool_use_id": "toolu_02",
          "result": {
            "paragraphs": [
              {
                "paragraph": 1,
                "text": "Supplier services agreement"
              },
              {
                "paragraph": 2,
                "text": "This agreement is between Harbor Lane Bakery Limited (the Customer) and Copperline Packaging Co. (the Supplier)."
              },
              {
                "paragraph": 3,
                "text": "1. Services. The Supplier will design and print the packaging described in each purchase order."
              },
              {
                "paragraph": 4,
                "text": "2. Payment. The Customer will pay each undisputed invoice within 15 days of receipt."
              },
              {
                "paragraph": 5,
                "text": "3. Prices. The Supplier may change its prices at any time."
              },
              {
                "paragraph": 6,
                "text": "4. Liability. The Supplier's total liability under this agreement is limited to the fees paid in the month before the claim."
              },
              {
                "paragraph": 7,
                "text": "5. Term. This agreement renews automatically for successive one-year terms unless either party gives 90 days' notice before the renewal date."
              },
              {
                "paragraph": 8,
                "text": "6. Confidentiality. Each party will keep the other party's confidential information secret for five years after this agreement ends."
              },
              {
                "paragraph": 9,
                "text": "7. Governing law. The laws of England and Wales govern this agreement."
              }
            ]
          }
        }
      ],
      "response": {
        "role": "assistant",
        "content": [
          {
            "type": "text",
            "text": "Four clauses conflict with the playbook: payment (15 days), prices (any time), liability (one month of fees) and renewal notice (90 days). Confidentiality (five years) and governing law comply."
          },
          {
            "type": "tool_use",
            "id": "toolu_03",
            "name": "propose_tracked_edit",
            "input": {
              "paragraph": 4,
              "find": "15 days",
              "replace": "45 days",
              "reason": "Playbook 1: the Customer pays undisputed invoices within 45 days."
            }
          },
          {
            "type": "tool_use",
            "id": "toolu_04",
            "name": "propose_tracked_edit",
            "input": {
              "paragraph": 5,
              "find": "at any time",
              "replace": "on 60 days' written notice to the Customer",
              "reason": "Playbook 2: price changes need 60 days' written notice."
            }
          },
          {
            "type": "tool_use",
            "id": "toolu_05",
            "name": "propose_tracked_edit",
            "input": {
              "paragraph": 6,
              "find": "the month before the claim",
              "replace": "the 12 months before the claim",
              "reason": "Playbook 3: the cap is at least the fees paid in the 12 months before the claim."
            }
          },
          {
            "type": "tool_use",
            "id": "toolu_06",
            "name": "propose_tracked_edit",
            "input": {
              "paragraph": 7,
              "find": "90 days notice",
              "replace": "30 days' notice",
              "reason": "Playbook 4: either party may stop renewal with 30 days' notice."
            }
          }
        ],
        "stop_reason": "tool_use"
      }
    },
    {
      "expect_tool_results": [
        {
          "tool_use_id": "toolu_03",
          "result": {
            "accepted": true,
            "paragraph": 4
          }
        },
        {
          "tool_use_id": "toolu_04",
          "result": {
            "accepted": true,
            "paragraph": 5
          }
        },
        {
          "tool_use_id": "toolu_05",
          "result": {
            "accepted": true,
            "paragraph": 6
          }
        },
        {
          "tool_use_id": "toolu_06",
          "result": {
            "accepted": false,
            "error": "find text does not occur in paragraph 7; quote it exactly as list_paragraphs shows it"
          }
        }
      ],
      "response": {
        "role": "assistant",
        "content": [
          {
            "type": "text",
            "text": "The renewal quote missed the apostrophe. Retrying with the exact text."
          },
          {
            "type": "tool_use",
            "id": "toolu_07",
            "name": "propose_tracked_edit",
            "input": {
              "paragraph": 7,
              "find": "90 days'",
              "replace": "30 days'",
              "reason": "Playbook 4: either party may stop renewal with 30 days' notice."
            }
          }
        ],
        "stop_reason": "tool_use"
      }
    },
    {
      "expect_tool_results": [
        {
          "tool_use_id": "toolu_07",
          "result": {
            "accepted": true,
            "paragraph": 7
          }
        }
      ],
      "response": {
        "role": "assistant",
        "content": [
          {
            "type": "tool_use",
            "id": "toolu_08",
            "name": "finish",
            "input": {
              "summary": "Proposed four tracked changes: payment within 45 days, 60 days' written notice for price changes, a liability cap of 12 months' fees, and 30 days' notice to stop renewal. Confidentiality and governing law already meet the playbook."
            }
          }
        ],
        "stop_reason": "tool_use"
      }
    },
    {
      "expect_tool_results": [
        {
          "tool_use_id": "toolu_08",
          "result": {
            "finished": true,
            "proposals": 4
          }
        }
      ],
      "response": {
        "role": "assistant",
        "content": [
          {
            "type": "text",
            "text": "Review complete: four tracked changes are ready for the Customer's lawyer to accept or reject."
          }
        ],
        "stop_reason": "end_turn"
      }
    }
  ]
}
```

### requirements.in

```text
# Direct dependencies. requirements.txt pins them and everything they need, with hashes.
# Live mode calls the Anthropic API with the standard library, so no SDK is needed.
wolfdocx==0.1.1
```

### requirements.txt

```text
# This file was autogenerated by uv via the following command:
#    uv pip compile requirements.in --universal --generate-hashes --python-version 3.11 -o requirements.txt
wolfdocx==0.1.1 \
    --hash=sha256:5ff5973bee21e35332a5534658d876fcba0690607a804bae9b51f356d74737f4 \
    --hash=sha256:c071234ce01a5b2645480ac0de4150af0297994fc529b2930d442603f27f1d04
    # via -r requirements.in
```

### run.py

```python
"""An agent loop that reviews a contract against a playbook and proposes tracked changes.

The model reads playbook.md and the contract's paragraphs through tools, then
proposes edits by quoting text. tools.py binds each accepted proposal to the
exact source paragraph; wolfdocx.review writes them all as native Word tracked
changes (w:del + w:ins with author and date) to out/supplier-agreement-redline.docx.
To show what the counterparty would get, the run also accepts the agent's
revisions into out/supplier-agreement-accepted.docx and prints a JSON summary.

    python run.py                          # replay/tool-calls.json, no API key
    python run.py --live --model MODEL     # Anthropic Messages API, ANTHROPIC_API_KEY
    python run.py --live --model MODEL --record replay/my-run.json
"""

from __future__ import annotations

import argparse
import json
import os
import sys
from collections import Counter
from datetime import UTC, datetime
from pathlib import Path
from typing import Any

from wolfdocx import Document, NodeType, review

from model import AnthropicModel, Recorder, ReplayModel
from tools import TOOLS, ReviewSession

HERE = Path(__file__).resolve().parent
SOURCE = HERE / "supplier-agreement.docx"
REDLINE = HERE / "out" / "supplier-agreement-redline.docx"
ACCEPTED = HERE / "out" / "supplier-agreement-accepted.docx"
AUTHOR = "Contract review agent"
MAX_TURNS = 12

SYSTEM = (
    "You review contracts for the Customer. Call read_playbook and list_paragraphs first. "
    "For every clause that conflicts with the playbook, call propose_tracked_edit once, quoting the "
    "smallest exact span to replace. If a proposal is refused, read the error and retry with a corrected "
    "quote. Do not edit clauses that already meet the playbook. Call finish when done."
)
TASK = "Review supplier-agreement.docx against the playbook and propose tracked changes."


def run_loop(model: Any, session: ReviewSession) -> tuple[int, Counter]:
    messages: list[dict[str, Any]] = [{"role": "user", "content": TASK}]
    calls: Counter = Counter()
    for turn in range(1, MAX_TURNS + 1):
        response = model.create(system=SYSTEM, tools=TOOLS, messages=messages)
        messages.append({"role": "assistant", "content": response["content"]})
        uses = [block for block in response["content"] if block["type"] == "tool_use"]
        if not uses:
            return turn, calls
        results = []
        for use in uses:
            calls[use["name"]] += 1
            result = session.call(use["name"], use["input"])
            results.append(
                {
                    "type": "tool_result",
                    "tool_use_id": use["id"],
                    "content": json.dumps(result, sort_keys=True),
                }
            )
        messages.append({"role": "user", "content": results})
    sys.exit(f"the agent did not stop within {MAX_TURNS} turns")


def paragraph_texts(path: Path) -> list[str]:
    with Document(path) as doc:
        return [
            p.get_text().rstrip("\r")
            for p in doc.node.get_child_nodes(NodeType.PARAGRAPH, deep=True)
        ]


def main() -> None:
    parser = argparse.ArgumentParser(
        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
    )
    parser.add_argument("--replay", type=Path, default=HERE / "replay" / "tool-calls.json")
    parser.add_argument(
        "--live", action="store_true", help="call the Anthropic Messages API instead of the replay"
    )
    parser.add_argument("--model", help="model name for --live")
    parser.add_argument("--record", type=Path, help="with --live, save the run as a replay file")
    args = parser.parse_args()

    if args.live:
        key = os.environ.get("ANTHROPIC_API_KEY")
        if not key or not args.model:
            sys.exit("--live needs --model and the ANTHROPIC_API_KEY environment variable")
        model: Any = Recorder(AnthropicModel(args.model, key))
        model_label = args.model
        review_date = datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
    else:
        model = ReplayModel(args.replay)
        model_label = f"replay:{args.replay.resolve().relative_to(HERE).as_posix()}"
        review_date = model.recording["review_date"]

    session = ReviewSession(SOURCE, HERE / "playbook.md")
    turns, calls = run_loop(model, session)
    if args.record and isinstance(model, Recorder):
        model.save(args.record, model=args.model, review_date=review_date)
    if session.summary is None:
        sys.exit("the agent stopped without calling finish")
    if not session.proposals:
        sys.exit("the agent proposed no accepted edits")

    REDLINE.parent.mkdir(exist_ok=True)
    for path in (REDLINE, ACCEPTED):
        path.unlink(missing_ok=True)  # wolfdocx never replaces an existing file.
    published = session.publish(REDLINE, author=AUTHOR, date=review_date)
    if not published.passed:
        sys.exit(f"tracked changes refused: {published.code}")
    revisions = review.discover_review_revisions(REDLINE)

    decision = review.decide_review(
        review.parse_review_decision_request(
            {
                "source": str(REDLINE),
                "source_sha256": published.output_sha256,
                "output": str(ACCEPTED),
                "action": "accept",
                "author_filter": AUTHOR,
            }
        )
    )
    if not decision.passed:
        sys.exit(f"accepting the agent's revisions refused: {decision.code}")

    original, accepted = paragraph_texts(SOURCE), paragraph_texts(ACCEPTED)
    summary = {
        "model": model_label,
        "turns": turns,
        "tool_calls": dict(sorted(calls.items())),
        "refused_proposals": session.refused,
        "redline": REDLINE.relative_to(HERE).as_posix(),
        "tracked_changes": [
            {key: revision[key] for key in ("kind", "author", "date", "paragraph", "text")}
            for revision in revisions["revisions"]
        ],
        "accepted": ACCEPTED.relative_to(HERE).as_posix(),
        "accepted_text": {
            str(number): accepted[number - 1] for number in sorted(session.proposals)
        },
        "unchanged_paragraphs": sum(
            1 for before, after in zip(original, accepted, strict=True) if before == after
        ),
        "reasons": {
            str(number): session.proposals[number]["reason"] for number in sorted(session.proposals)
        },
        "agent_summary": session.summary,
    }
    print(json.dumps(summary, indent=2, sort_keys=True, ensure_ascii=False))


if __name__ == "__main__":
    main()
```

### supplier-agreement.docx

Binary sample, 2186 bytes, SHA-256 `74e4b1dc374c1f3698857987ee1bb3dca70be22204a644403241ad613dd3ee1a`.

### tools.py

```python
"""The four tools the review agent may call, implemented with wolfdocx.review.

The model never sees offsets or hashes. It quotes text; this module turns each
quote into a source-bound change (paragraph, character range, structure
fingerprint) and refuses quotes that are missing, ambiguous, or outside the
editable text, so a bad proposal comes back to the model as a tool error
instead of reaching the document.
"""

from __future__ import annotations

from pathlib import Path
from typing import Any

from wolfdocx import review

TOOLS: list[dict[str, Any]] = [
    {
        "name": "read_playbook",
        "description": "Return the review playbook: the positions the contract must meet.",
        "input_schema": {"type": "object", "properties": {}, "additionalProperties": False},
    },
    {
        "name": "list_paragraphs",
        "description": "Return the contract's editable paragraphs with their 1-based numbers and text.",
        "input_schema": {"type": "object", "properties": {}, "additionalProperties": False},
    },
    {
        "name": "propose_tracked_edit",
        "description": (
            "Propose one tracked change: replace the exact text `find` in paragraph `paragraph` with `replace`. "
            "`find` must occur exactly once in that paragraph; quote the smallest span that changes. "
            "At most one proposal per paragraph."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "paragraph": {"type": "integer", "minimum": 1},
                "find": {"type": "string", "minLength": 1},
                "replace": {"type": "string"},
                "reason": {
                    "type": "string",
                    "description": "The playbook position this edit enforces.",
                },
            },
            "required": ["paragraph", "find", "replace", "reason"],
            "additionalProperties": False,
        },
    },
    {
        "name": "finish",
        "description": "End the review once every conflicting clause has an accepted proposal.",
        "input_schema": {
            "type": "object",
            "properties": {"summary": {"type": "string"}},
            "required": ["summary"],
            "additionalProperties": False,
        },
    },
]


class ReviewSession:
    """Tool state for one contract: proposals are collected, then published once."""

    def __init__(self, source: Path, playbook: Path) -> None:
        self.source = source
        self.playbook = playbook.read_text(encoding="utf-8")
        found = review.discover_review_targets(source)
        if not found["passed"]:
            raise SystemExit(f"cannot review {source.name}: {found['code']}")
        self.source_sha256 = found["source_sha256"]
        self.targets = {target["paragraph"]: target for target in found["targets"]}
        self.proposals: dict[int, dict[str, Any]] = {}
        self.refused: list[dict[str, Any]] = []
        self.summary: str | None = None

    def call(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
        handler = {
            "read_playbook": self.read_playbook,
            "list_paragraphs": self.list_paragraphs,
            "propose_tracked_edit": self.propose_tracked_edit,
            "finish": self.finish,
        }.get(name)
        if handler is None:
            return {"error": f"unknown tool {name!r}"}
        return handler(**arguments)

    def read_playbook(self) -> dict[str, Any]:
        return {"playbook": self.playbook}

    def list_paragraphs(self) -> dict[str, Any]:
        return {
            "paragraphs": [
                {"paragraph": number, "text": target["text"]}
                for number, target in sorted(self.targets.items())
                if target["editable_ranges"]
            ]
        }

    def propose_tracked_edit(
        self, paragraph: int, find: str, replace: str, reason: str
    ) -> dict[str, Any]:
        error = self._check(paragraph, find, replace)
        if error:
            self.refused.append({"paragraph": paragraph, "find": find, "error": error})
            return {"accepted": False, "error": error}
        start = self.targets[paragraph]["text"].index(find)
        self.proposals[paragraph] = {
            "change": {
                "part": self.targets[paragraph]["part"],
                "paragraph": paragraph,
                "start": start,
                "end": start + len(find),
                "text": replace,
                "structure_fingerprint_sha256": self.targets[paragraph][
                    "structure_fingerprint_sha256"
                ],
            },
            "find": find,
            "reason": reason,
        }
        return {"accepted": True, "paragraph": paragraph}

    def _check(self, paragraph: int, find: str, replace: str) -> str | None:
        target = self.targets.get(paragraph)
        if target is None:
            return f"paragraph {paragraph} does not exist or is not editable"
        if paragraph in self.proposals:
            return f"paragraph {paragraph} already has a proposal"
        if find == replace:
            return "find and replace are the same text"
        text = target["text"]
        count = text.count(find)
        if count != 1:
            where = "does not occur" if count == 0 else f"occurs {count} times"
            return f"find text {where} in paragraph {paragraph}; quote it exactly as list_paragraphs shows it"
        start = text.index(find)
        if not any(
            r["start"] <= start and start + len(find) <= r["end"] for r in target["editable_ranges"]
        ):
            return f"find text in paragraph {paragraph} is not inside an editable range"
        return None

    def finish(self, summary: str) -> dict[str, Any]:
        self.summary = summary
        return {"finished": True, "proposals": len(self.proposals)}

    def publish(self, output: Path, *, author: str, date: str) -> review.ReviewReport:
        """Write every accepted proposal as a native Word tracked change."""
        request = review.parse_review_request(
            {
                "source": str(self.source),
                "source_sha256": self.source_sha256,
                "output": str(output),
                "author": author,
                "date": date,
                "changes": [self.proposals[number]["change"] for number in sorted(self.proposals)],
            }
        )
        return review.author_review(request)
```
