Skip to content

Agent-tool runs

Agent-tool runs: the run directory of format 2, its call log, its lock and its replay.

An agent in its own harness builds a graph through the package's tools. Each tool call that changes the run is one write: it takes the run's lock, applies an operation to the current state, and commits. The commit point is one line appended to calls.jsonl, so the log is the record of truth and every other file is derived from it:

  1. the new state is written to state.json.tmp;
  2. the call's line is appended to calls.jsonl and flushed to disk;
  3. state.json.tmp is renamed to state.json, and manifest.json is rewritten the same way.

A process that dies before step 2 leaves no trace; one that dies after it leaves a state.json that names fewer calls than the log, which the next tool call finds and rebuilds by applying the logged calls to the graph start_run ingested (ingested.json). Ids are allocated from the state under the lock, so the calls of several agents, applied in log order, give the same ids again.

The layout::

<run>/
  manifest.json   format "credencegraph-extract-run", version 2
  calls.jsonl     every write, in the order it took effect
  state.json      the state after the last call, and how many calls it reflects
  ingested.json   the graph start_run ingested, the start of every rebuild
  rubric.toml     byte copy of the rubric
  cited/          byte copies of the cited works, made by start_run
  checks/c-NNN/   each check's inputs, output and log, written from the state
  source/         a copy of the document, only with store_source
  sources/        the bytes of each fetched source, named by digest, only with store_source
  graph.json      the graph, only while the run is finished
  rejections.json every refused record, only while the run is finished
  report.json     coverage, untied citation checks and checks made with a model their type was
                  not fitted with, only while the run is finished

replay re-ingests the document and applies every logged call again, checking each call's result digest; the graph it ends with must be byte-identical to the run's. The log records no local path: a file is named by its name and digest.

Context dataclass

What an operation may read beside the state: the run and its fixed inputs.

Attributes:

Name Type Description
run_dir Path

The run directory.

run_id str

The run's id.

rubric Rubric

The run's rubric.

call int

The number of the call being applied, from 1 for start_run.

cited Mapping[str, str]

The digest of each file of the run's copy of the cited works, as start_run recorded it.

model str | None

The model the call reports.

replaying bool

Whether the call is applied by replay, which reads every source again from its bytes; a live call or a rebuild takes what the log recorded.

sources Path | None

Where a replay finds fetched sources the run did not store, as files named by the hex of their digest.

Source code in src/credencegraph_extract/session.py
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
@dataclass(frozen=True)
class Context:
    """What an operation may read beside the state: the run and its fixed inputs.

    Attributes:
        run_dir: The run directory.
        run_id: The run's id.
        rubric: The run's rubric.
        call: The number of the call being applied, from 1 for ``start_run``.
        cited: The digest of each file of the run's copy of the cited works, as ``start_run`` recorded it.
        model: The model the call reports.
        replaying: Whether the call is applied by ``replay``, which reads every source again from its
            bytes; a live call or a rebuild takes what the log recorded.
        sources: Where a replay finds fetched sources the run did not store, as files named by the hex
            of their digest.
    """

    run_dir: Path
    run_id: str
    rubric: Rubric
    call: int
    cited: Mapping[str, str] = field(default_factory=dict)
    model: str | None = None
    replaying: bool = False
    sources: Path | None = None

State dataclass

What a run holds between tool calls.

Attributes:

Name Type Description
graph Graph

The graph so far.

rejections tuple[Mapping[str, Any], ...]

Every refused record or proposal, with the call that sent it.

finished bool

Whether the last write was finish_run; any other write makes it False.

files Mapping[str, str]

The files of the checks run, by path in the run (checks/c-NNN/<name>), as text.

Source code in src/credencegraph_extract/session.py
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
@dataclass(frozen=True)
class State:
    """What a run holds between tool calls.

    Attributes:
        graph: The graph so far.
        rejections: Every refused record or proposal, with the call that sent it.
        finished: Whether the last write was ``finish_run``; any other write makes it ``False``.
        files: The files of the checks run, by path in the run (``checks/c-NNN/<name>``), as text.
    """

    graph: Graph
    rejections: tuple[Mapping[str, Any], ...] = ()
    finished: bool = False
    files: Mapping[str, str] = field(default_factory=dict)

current_state(run_dir)

Return the run's state, rebuilt from the log when state.json does not reflect all of it.

The caller holds the lock.

Parameters:

Name Type Description Default
run_dir Path

The run directory.

required

Returns:

Type Description
State

The state after every logged call.

Raises:

Type Description
RunError

If the run directory is malformed or incomplete.

Source code in src/credencegraph_extract/session.py
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
def current_state(run_dir: Path) -> State:
    """Return the run's state, rebuilt from the log when ``state.json`` does not reflect all of it.

    The caller holds the lock.

    Args:
        run_dir: The run directory.

    Returns:
        The state after every logged call.

    Raises:
        RunError: If the run directory is malformed or incomplete.
    """
    manifest = read_manifest(run_dir)
    return _load(run_dir, manifest, read_calls(run_dir))[0]

get_state(run_dir)

Return what a run holds now, for an agent deciding its next call.

Parameters:

Name Type Description Default
run_dir Path

The run directory.

required

Returns:

Type Description
dict[str, Any]

Every node's id, type and statement, with its assessment where it has one; the relations; the

dict[str, Any]

refused records so far; how many calls the log holds; and whether the run is finished.

Raises:

Type Description
RunError

If the run directory is malformed or incomplete.

Source code in src/credencegraph_extract/session.py
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
def get_state(run_dir: Path) -> dict[str, Any]:
    """Return what a run holds now, for an agent deciding its next call.

    Args:
        run_dir: The run directory.

    Returns:
        Every node's id, type and statement, with its assessment where it has one; the relations; the
        refused records so far; how many calls the log holds; and whether the run is finished.

    Raises:
        RunError: If the run directory is malformed or incomplete.
    """
    with lock(run_dir):
        manifest = read_manifest(run_dir)
        all_calls = read_calls(run_dir)
        state = _reconcile(run_dir, manifest, _rubric(run_dir, manifest), all_calls)
        calls = len(all_calls)
    nodes = []
    for node in state.graph:
        item: dict[str, Any] = {"id": node.id, "type": node.kind, "text": node.statement}
        if "assessment" in node.attributes:
            item["assessment"] = node.attributes["assessment"]
        if "check" in node.attributes:
            item["result"] = node.attributes["check"].get("result")
        nodes.append(item)
    relations = [
        {"type": relation.type, "from": relation.source, "to": relation.target}
        for relation in state.graph.relations.values()
    ]
    return {
        "nodes": nodes,
        "relations": relations,
        "rejections": [dict(item) for item in state.rejections],
        "calls": calls,
        "finished": state.finished,
    }

lock(run_dir, timeout=LOCK_TIMEOUT)

Hold the run's lock: one tool call at a time, across processes.

The operating system releases the lock when its process exits, so a crashed agent leaves none behind. A call that cannot take the lock within timeout seconds reports the run busy.

Parameters:

Name Type Description Default
run_dir Path

The run directory.

required
timeout float

How long to wait, in seconds.

LOCK_TIMEOUT

Yields:

Type Description
None

Nothing; the lock is held until the block ends.

Raises:

Type Description
RunError

If the lock is not free within timeout.

Source code in src/credencegraph_extract/session.py
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
@contextmanager
def lock(run_dir: Path, timeout: float = LOCK_TIMEOUT) -> Iterator[None]:
    """Hold the run's lock: one tool call at a time, across processes.

    The operating system releases the lock when its process exits, so a crashed agent leaves none
    behind. A call that cannot take the lock within ``timeout`` seconds reports the run busy.

    Args:
        run_dir: The run directory.
        timeout: How long to wait, in seconds.

    Yields:
        Nothing; the lock is held until the block ends.

    Raises:
        RunError: If the lock is not free within ``timeout``.
    """
    with (run_dir / LOCK).open("a+b") as handle:
        if handle.seek(0, os.SEEK_END) == 0:
            handle.write(b"\0")
            handle.flush()
        deadline = time.monotonic() + timeout
        while not _try_lock(handle):
            if time.monotonic() >= deadline:
                holder = _owner(run_dir)
                by = (
                    f" (process {holder.get('pid')}, since {holder.get('since')})" if holder and "pid" in holder else ""
                )
                raise RunError("run-busy", f"another tool call holds the lock of {run_dir.name!r}{by}")
            time.sleep(0.05)
        owner = run_dir / LOCK_OWNER
        try:
            owner.write_text(json.dumps({"pid": os.getpid(), "since": _now()}), encoding="utf-8")
            yield
        finally:
            owner.unlink(missing_ok=True)
            _unlock(handle)

lock_holder(run_dir)

Return who holds a run's lock, or None when no tool call does.

The lock is the operating system's: it is released when its process exits, so a crashed agent leaves none behind, and only a live process (a hung one, say) can hold it. A free lock's leftover owner record, from a process that died holding it, is removed.

Parameters:

Name Type Description Default
run_dir Path

The run directory.

required

Returns:

Type Description
dict[str, Any] | None

{"pid", "since"} of the holding process (either may be missing when it wrote no record yet),

dict[str, Any] | None

or None.

Raises:

Type Description
RunError

If the directory is not a run.

Source code in src/credencegraph_extract/session.py
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
def lock_holder(run_dir: Path) -> dict[str, Any] | None:
    """Return who holds a run's lock, or ``None`` when no tool call does.

    The lock is the operating system's: it is released when its process exits, so a crashed agent
    leaves none behind, and only a live process (a hung one, say) can hold it. A free lock's leftover
    owner record, from a process that died holding it, is removed.

    Args:
        run_dir: The run directory.

    Returns:
        ``{"pid", "since"}`` of the holding process (either may be missing when it wrote no record yet),
        or ``None``.

    Raises:
        RunError: If the directory is not a run.
    """
    if not (run_dir / CALLS).is_file():
        raise RunError("missing-run", f"{run_dir.name!r} is not a run directory")
    with (run_dir / LOCK).open("a+b") as handle:
        if handle.seek(0, os.SEEK_END) == 0:
            handle.write(b"\0")
            handle.flush()
        if _try_lock(handle):
            # A record left by a process that died holding the lock; removed while the lock is held, so a
            # call that takes it next cannot have its own record removed.
            try:
                (run_dir / LOCK_OWNER).unlink(missing_ok=True)
            finally:
                _unlock(handle)
            return None
    return _owner(run_dir) or {}

operation(name)

Register a write tool's operation under its name.

Parameters:

Name Type Description Default
name str

The tool's name, as the call log records it.

required

Returns:

Type Description
Callable[[Operation], Operation]

A decorator registering the operation.

Source code in src/credencegraph_extract/session.py
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
def operation(name: str) -> Callable[[Operation], Operation]:
    """Register a write tool's operation under its name.

    Args:
        name: The tool's name, as the call log records it.

    Returns:
        A decorator registering the operation.
    """

    def register(fn: Operation) -> Operation:
        OPERATIONS[name] = fn
        return fn

    return register

read_calls(run_dir)

Read the call log.

Parameters:

Name Type Description Default
run_dir Path

The run directory.

required

Returns:

Type Description
list[dict[str, Any]]

The calls, in the order they took effect.

Raises:

Type Description
RunError

If a line is not a call object, or the calls are not numbered 1, 2, … from start_run.

Source code in src/credencegraph_extract/session.py
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
def read_calls(run_dir: Path) -> list[dict[str, Any]]:
    """Read the call log.

    Args:
        run_dir: The run directory.

    Returns:
        The calls, in the order they took effect.

    Raises:
        RunError: If a line is not a call object, or the calls are not numbered 1, 2, … from ``start_run``.
    """
    calls = []
    for number, line in enumerate(_read(run_dir, CALLS).splitlines(), start=1):
        try:
            call = json.loads(line)
        except ValueError as error:
            raise RunError("invalid-record", f"{CALLS} line {number} is not JSON: {error}") from None
        if not isinstance(call, dict) or call.get("seq") != number or not isinstance(call.get("tool"), str):
            raise RunError("invalid-record", f"{CALLS} line {number} is not call {number}")
        calls.append(call)
    if not calls or calls[0]["tool"] != START:
        raise RunError("invalid-record", f"{CALLS} does not begin with {START}")
    return calls

read_manifest(run_dir)

Read a format-2 run's manifest.

Parameters:

Name Type Description Default
run_dir Path

The run directory.

required

Returns:

Type Description
dict[str, Any]

The manifest.

Raises:

Type Description
RunError

If the directory is not a run of this format and version.

Source code in src/credencegraph_extract/session.py
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
def read_manifest(run_dir: Path) -> dict[str, Any]:
    """Read a format-2 run's manifest.

    Args:
        run_dir: The run directory.

    Returns:
        The manifest.

    Raises:
        RunError: If the directory is not a run of this format and version.
    """
    if not (run_dir / MANIFEST).is_file():
        raise RunError("not-a-run", f"{run_dir.name!r} has no {MANIFEST}")
    try:
        manifest = json.loads(_read(run_dir, MANIFEST))
    except ValueError as error:
        raise RunError("invalid-manifest", f"{MANIFEST} is not JSON: {error}") from None
    if not isinstance(manifest, dict) or manifest.get("format") != FORMAT:
        raise RunError("not-a-run", f"{MANIFEST} is not a {FORMAT} manifest")
    if manifest.get("version") != VERSION:
        raise RunError("unsupported-version", f"{MANIFEST} is version {manifest.get('version')!r}, not {VERSION}")
    return manifest

reconcile(run_dir)

Bring a run's state, manifest and outputs back in line with its call log, under the lock.

Parameters:

Name Type Description Default
run_dir Path

The run directory.

required

Raises:

Type Description
RunError

If the run directory is malformed or incomplete.

Source code in src/credencegraph_extract/session.py
696
697
698
699
700
701
702
703
704
705
706
707
def reconcile(run_dir: Path) -> None:
    """Bring a run's state, manifest and outputs back in line with its call log, under the lock.

    Args:
        run_dir: The run directory.

    Raises:
        RunError: If the run directory is malformed or incomplete.
    """
    with lock(run_dir):
        manifest = read_manifest(run_dir)
        _reconcile(run_dir, manifest, _rubric(run_dir, manifest), read_calls(run_dir))

recorded_graph(run_dir)

Return the run's graph after every logged call, as replay returns it.

Parameters:

Name Type Description Default
run_dir Path

The run directory.

required

Returns:

Type Description
bytes

The graph, as the core serialises it, with a final newline.

Source code in src/credencegraph_extract/session.py
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
def recorded_graph(run_dir: Path) -> bytes:
    """Return the run's graph after every logged call, as ``replay`` returns it.

    Args:
        run_dir: The run directory.

    Returns:
        The graph, as the core serialises it, with a final newline.
    """
    with lock(run_dir):
        state = current_state(run_dir)
    return _graph_text(state.graph)

replay(run_dir, *, source=None, sources=None)

Re-ingest a run's document and apply every logged call again, without the network.

Each call's result must have the digest the log recorded; the graph the last call leaves is returned, as state.json holds it, for the caller to compare with the run's.

Parameters:

Name Type Description Default
run_dir Path

The run directory.

required
source Path | None

The document, for a run that did not store it.

None
sources Path | None

The fetched sources the run did not store, as files named by their digest.

None

Returns:

Type Description
bytes

The rebuilt graph, as the core serialises it, with a final newline.

Raises:

Type Description
RunError

If the run directory is malformed or incomplete, the document, rubric or a cited copy differs from what the run recorded, or a call's result differs from its record.

Source code in src/credencegraph_extract/session.py
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
def replay(run_dir: Path, *, source: Path | None = None, sources: Path | None = None) -> bytes:
    """Re-ingest a run's document and apply every logged call again, without the network.

    Each call's result must have the digest the log recorded; the graph the last call leaves is
    returned, as ``state.json`` holds it, for the caller to compare with the run's.

    Args:
        run_dir: The run directory.
        source: The document, for a run that did not store it.
        sources: The fetched sources the run did not store, as files named by their digest.

    Returns:
        The rebuilt graph, as the core serialises it, with a final newline.

    Raises:
        RunError: If the run directory is malformed or incomplete, the document, rubric or a cited
            copy differs from what the run recorded, or a call's result differs from its record.
    """
    manifest = read_manifest(run_dir)
    calls = read_calls(run_dir)
    start = calls[0]["args"]
    recorded = start["source"]
    if recorded.get("stored"):
        source = run_dir / SOURCE / recorded["file"]
    elif source is None:
        raise RunError("missing-source", "the run did not store its document: pass the source document")
    try:
        source_bytes = source.read_bytes()
    except OSError as error:
        raise RunError("missing-source", f"cannot read {source.name!r}: {error.strerror or error}") from None
    if bytes_digest(source_bytes) != recorded["digest"]:
        raise RunError("source-mismatch", f"{source.name!r} is not the document the run read")
    rubric = _rubric(run_dir, manifest)
    for name, digest in start["cited"].items():
        if bytes_digest(_read(run_dir, f"{CITED}/{name}")) != digest:
            raise RunError("record-mismatch", f"the cited copy {name!r} differs from the one start_run made")
    graph = _ingest(source, source_bytes, start["document"], rubric)
    graph = stamp(graph, Graph(), {"run": manifest["run"], "tool": START})
    state = State(graph)
    _check_digests(calls[0], state, _start_result(graph, rubric, manifest["run"]))
    for call in calls[1:]:
        context = _context(run_dir, manifest, rubric, calls, call, replaying=True, sources=sources)
        state, result = _apply(context, state, call)
        _check_digests(call, state, result)
    return _graph_text(state.graph)

rubric_problems(rubric)

Return why a rubric cannot be used for an agent-tool run, or nothing if it can.

Parameters:

Name Type Description Default
rubric Rubric

The rubric.

required

Returns:

Type Description
list[str]

Every problem: an edge rule that creates a relation other than requires, and a

list[str]

[premises] table without the analyst assumption type it needs.

Source code in src/credencegraph_extract/session.py
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
def rubric_problems(rubric: Rubric) -> list[str]:
    """Return why a rubric cannot be used for an agent-tool run, or nothing if it can.

    Args:
        rubric: The rubric.

    Returns:
        Every problem: an edge rule that creates a relation other than ``requires``, and a
        ``[premises]`` table without the analyst ``assumption`` type it needs.
    """
    problems = []
    refused = edge_rules_not_requires(rubric)
    if refused:
        problems.append(
            f"the edge rules of {refused} create relations other than requires, which would let a document "
            "raise the credence of its own claims"
        )
    if rubric.premises is not None:
        problem = premises_rubric_problem(rubric)
        if problem:
            problems.append(f"{problem}, which its [premises] table needs")
    return problems

stamp(graph, before, mark)

Stamp every node of graph that is new or changed since before with attributes.extraction.

Parameters:

Name Type Description Default
graph Graph

The graph after a change.

required
before Graph

The graph before it.

required
mark Mapping[str, Any]

The stamp, such as {"run": <run id>, "tool": <tool>}.

required

Returns:

Type Description
Graph

The stamped graph.

Source code in src/credencegraph_extract/session.py
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
def stamp(graph: Graph, before: Graph, mark: Mapping[str, Any]) -> Graph:
    """Stamp every node of ``graph`` that is new or changed since ``before`` with ``attributes.extraction``.

    Args:
        graph: The graph after a change.
        before: The graph before it.
        mark: The stamp, such as ``{"run": <run id>, "tool": <tool>}``.

    Returns:
        The stamped graph.
    """
    stamped = Graph()
    for node in graph:
        changed = before.nodes.get(node.id) != node
        attributes = {**node.attributes, "extraction": dict(mark)}
        stamped.add_node(replace(node, attributes=attributes) if changed else node)
    for relation in graph.relations.values():
        stamped.add_relation(relation)
    return stamped

start_run(source, rubric, run, *, harness, model, agent=None, note=None, document=None, cited=None, store_source=False)

Start a run: check the rubric, ingest the document, copy the cited works, and log the call.

Everything is read and checked before the run directory is written, so a rubric the tools cannot use, an unreadable document or a cited directory holding a link leaves nothing behind.

Parameters:

Name Type Description Default
source Path

The document's main .tex file.

required
rubric Path

The rubric file.

required
run Path

The run directory to create; it must not exist or be empty.

required
harness str

The agent harness, as it reports itself.

required
model str

The model making the call.

required
agent str | None

A name for the agent.

None
note str | None

Free text kept with the call.

None
document str | None

The document id of every anchor; the source's file name by default.

None
cited Path | None

The directory of cited works, each a file named by its bibliography key; copied into the run with each file's digest, and the copy is the only one the citation tool reads.

None
store_source bool

Copy the document into the run.

False

Returns:

Type Description
dict[str, Any]

The run's id, the chunks, the document types with their form fields and edge rules, the hedge

dict[str, Any]

phrases, the check types the tools run, and those the rubric declares but the tools do not.

Raises:

Type Description
RunError

If the run directory is in use, the rubric cannot be used, the document cannot be read, or the cited works are not plain files.

Source code in src/credencegraph_extract/session.py
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
def start_run(  # noqa: PLR0913 - each argument is a separate choice of the caller
    source: Path,
    rubric: Path,
    run: Path,
    *,
    harness: str,
    model: str,
    agent: str | None = None,
    note: str | None = None,
    document: str | None = None,
    cited: Path | None = None,
    store_source: bool = False,
) -> dict[str, Any]:
    """Start a run: check the rubric, ingest the document, copy the cited works, and log the call.

    Everything is read and checked before the run directory is written, so a rubric the tools cannot
    use, an unreadable document or a cited directory holding a link leaves nothing behind.

    Args:
        source: The document's main ``.tex`` file.
        rubric: The rubric file.
        run: The run directory to create; it must not exist or be empty.
        harness: The agent harness, as it reports itself.
        model: The model making the call.
        agent: A name for the agent.
        note: Free text kept with the call.
        document: The document id of every anchor; the source's file name by default.
        cited: The directory of cited works, each a file named by its bibliography key; copied into
            the run with each file's digest, and the copy is the only one the citation tool reads.
        store_source: Copy the document into the run.

    Returns:
        The run's id, the chunks, the document types with their form fields and edge rules, the hedge
        phrases, the check types the tools run, and those the rubric declares but the tools do not.

    Raises:
        RunError: If the run directory is in use, the rubric cannot be used, the document cannot be
            read, or the cited works are not plain files.
    """
    identity = _identity(model, agent, note)
    if not isinstance(harness, str) or not harness.strip():
        raise RunError("invalid-argument", "harness must name the agent harness")
    if run.exists() and any(run.iterdir()):
        raise RunError("run-exists", f"{run.name!r} exists and is not empty")
    read = _read_start(source, rubric, cited)
    if document is not None and not isinstance(document, str):
        raise RunError("invalid-argument", "document must be an id for the document, a string")
    if document is not None and (
        not document.strip() or any(sep in document for sep in ("/", "\\")) or PureWindowsPath(document).drive
    ):
        raise RunError("invalid-argument", f"document {document!r} must be an id for the document, not a path")
    document = document or source.name
    run_id = new_run_id()
    graph = stamp(_ingest(source, read.source_bytes, document, read.rubric), Graph(), {"run": run_id, "tool": START})
    args = {
        "document": document,
        "source": {"file": source.name, "digest": bytes_digest(read.source_bytes), "stored": store_source},
        "rubric_digest": bytes_digest(read.rubric_bytes),
        "cited": {name: bytes_digest(data) for name, data in read.cited.items()},
        "harness": harness,
    }
    result = _start_result(graph, read.rubric, run_id)
    # Build the run in a temporary directory beside it and rename it into place, so a start that fails
    # part-way leaves nothing that a second start_run would refuse as a run in use.
    building = run.parent / f".{run.name}.{run_id}.starting"
    building.mkdir(parents=True)
    try:
        _populate(building, read, graph, result, args, identity, run_id, store_source, source.name, document)
        if run.exists():
            run.rmdir()
        building.rename(run)
    except BaseException:
        shutil.rmtree(building, ignore_errors=True)
        raise
    return result

versions()

Return the versions a run depends on: Python, the core, this package and sympy.

Returns:

Type Description
dict[str, str]

The versions by name.

Source code in src/credencegraph_extract/session.py
185
186
187
188
189
190
191
192
193
194
195
196
def versions() -> dict[str, str]:
    """Return the versions a run depends on: Python, the core, this package and sympy.

    Returns:
        The versions by name.
    """
    return {
        "python": platform.python_version(),
        "credencegraph": version("credencegraph"),
        "credencegraph-extract": __version__,
        "sympy": version("sympy"),
    }

write(run_dir, tool, args, *, model, agent=None, note=None)

Make one write to a run: apply a tool's operation under the lock and commit it.

Parameters:

Name Type Description Default
run_dir Path

The run directory.

required
tool str

The tool, a registered operation.

required
args Mapping[str, Any]

Its arguments, as JSON values.

required
model str

The model making the call, as the harness reports it.

required
agent str | None

A name for the agent, for runs with several.

None
note str | None

Free text kept with the call, never used in inference.

None

Returns:

Type Description
dict[str, Any]

The tool's result.

Raises:

Type Description
RunError

If the tool is unknown, the run is busy or malformed, or the operation refuses the call as a whole.

Source code in src/credencegraph_extract/session.py
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
def write(  # noqa: PLR0913 - the call and who makes it
    run_dir: Path,
    tool: str,
    args: Mapping[str, Any],
    *,
    model: str,
    agent: str | None = None,
    note: str | None = None,
) -> dict[str, Any]:
    """Make one write to a run: apply a tool's operation under the lock and commit it.

    Args:
        run_dir: The run directory.
        tool: The tool, a registered operation.
        args: Its arguments, as JSON values.
        model: The model making the call, as the harness reports it.
        agent: A name for the agent, for runs with several.
        note: Free text kept with the call, never used in inference.

    Returns:
        The tool's result.

    Raises:
        RunError: If the tool is unknown, the run is busy or malformed, or the operation refuses the
            call as a whole.
    """
    identity = _identity(model, agent, note)
    if tool not in _operations():
        raise RunError("unknown-tool", f"{tool!r} is not a tool that changes a run")
    with lock(run_dir):
        manifest = read_manifest(run_dir)
        rubric = _rubric(run_dir, manifest)
        calls = read_calls(run_dir)
        state = _reconcile(run_dir, manifest, rubric, calls)
        seq = len(calls) + 1
        # The call is applied to its arguments as the log will hold them, so a replay, which reads them
        # back from the log, gives the same result whatever order the agent wrote their keys in.
        call = {"seq": seq, "tool": tool, "args": json.loads(_line(dict(args))), **identity}
        after, result = _apply(_context(run_dir, manifest, rubric, calls, call), state, call)
        line = _line({**call, "call_digest": _call_digest(call), **_digests(after, result), "at": _now()})
        log_digest = bytes_digest(_read(run_dir, CALLS) + line)
        temporary = run_dir / (STATE + ".tmp")
        _durable_write(temporary, _state_file(after, seq, log_digest))
        with (run_dir / CALLS).open("ab") as log:
            log.write(line)
            log.flush()
            os.fsync(log.fileno())
        temporary.replace(run_dir / STATE)
        _replace(run_dir / MANIFEST, _manifest(manifest, [*calls, json.loads(line)], after, log_digest))
        _check_files(run_dir, after)
        _outputs(run_dir, rubric, after)
    return result