Scoped runtime for embedding services¶
chemgraph.runtime lets an application such as a lab service run the main
agent for authenticated callers. Every permitted tool call is reviewed against
an exact preparation, executed at most once, and its outputs are kept as
immutable artifacts. ChemGraph owns preparation, execution states and
artifacts. The application owns authentication, decision records, storage and
transport.
This is an application boundary, not an operating-system sandbox. Calculator packages and model files are provisioned by the operator.
For a runnable example, see Scoped MACE-small optimization. It downloads or reuses MACE-small weights during setup, requests human approval, and verifies the saved optimization result. Run it from the repository root:
Building and running a scoped agent¶
from chemgraph.agent.llm_agent import ChemGraph
from chemgraph.agent.main_session import MainAgentSession
from chemgraph.registry.tools import ToolRegistry
from chemgraph.runtime import (
ExecutionPolicy, FileOperationStore, OperationJournal, RunContext, run_context,
)
policy = ExecutionPolicy(
calculators={"emt", "mace_mp"},
model_roots=["/opt/models"],
model_resources={"mace_mp:small": "/opt/models/mace-small.model"},
limits={"max_steps": 500, "max_atoms": 200},
)
journal = OperationJournal(FileOperationStore("/srv/state/operations"))
await journal.recover() # once at startup
context = RunContext(owner="alice", thread_id=thread, task_id=task,
workspace=f"/srv/state/workspaces/{thread}",
policy=policy, journal=journal)
agent = ChemGraph(
workflow_type="main_agent", model_name="gpt-4o-mini",
approval_mode="scoped",
tool_registry=ToolRegistry(s for s in ToolRegistry().specs() if s.name in policy.tools),
subagent_names=[], discover_skills=False, checkpointer=saver,
)
session = MainAgentSession(agent.workflow, thread_id=thread)
with run_context(context):
turn = await session.run("Optimize water with EMT")
Keep one RunContext per conversation, change only task_id between
serialized turns, and give each conversation its own workspace. Scoped graphs
keep agent files in checkpoint state and expose no shell. They also have no
delegation and no approval interrupts other than the ones described below.
What the model can call¶
- Framework helpers (
policy.helpers: todos, checkpoint file reads, tool discovery,ask_human) run directly. - Tools outside
policy.toolsreturn an error message to the model and do not run. - Permitted tools go through review. The permitted set is the ASE tools that
have an exact preparation:
run_ase, the structure readers and writers, and the SMILES helpers.
Supported calculators are EMT and MACE (mace_mp, mace_off, mace_anicc).
A MACE model is used only through a model_resources binding to a weights file
under model_roots; model names alone are rejected. Calculator commands,
directories and model URLs are never accepted from tool arguments.
Review¶
For each permitted call, OperationJournal.propose resolves defaults, copies
referenced input files to .chemgraph-inputs/<sha256>/, and saves the
preparation and its digest. If the call can't be prepared, for example because
its input doesn't exist yet, the model gets the reason without a review.
When a call needs a decision, the graph pauses with one interrupt per call:
{"kind": "approval", "operation_id": ..., "tool_call_id": ..., "tool": ...,
"digest": ..., "prepared": {...}}
Record the authenticated decision, then resume with any value:
await journal.decide(operation_id, digest=digest, approve=True,
decided_by="alice", expires_at=time.time() + 600)
with run_context(context):
turn = await session.resume("decided")
The resume value never authorizes anything; the middleware reads the decision
from the journal. An undecided or expired operation pauses again, including
one that is rejected or expires while it waits to run: the approval is checked
again atomically when invoking is saved. A rejection
reaches the model as an error message, and the decision's message explains
why. Execution uses the prepared arguments and snapshots, so later changes to
the original files cannot alter an approved action. If a snapshot or model
file changes after approval, the call is blocked and has to be proposed again.
Execution states and recovery¶
proposed -> approved -> invoking -> returned -> finalized
rejected uncertain (artifacts published)
blocked abandoned
invokingis saved before the tool runs andreturned, with the result and the registered output paths, right after.- An exception raised by the tool is a known outcome, so it is saved as an error result and finalized like any other result, with any outputs it registered.
- Finalization saves a checksum manifest of the registered outputs, then
publishes exactly those files. If either step fails, the operation stays
returned; retrying the turn finalizes it without running the calculation again. - A finalized operation replays its saved result after verifying its artifact checksums.
journal.recover()marks interrupted invocationsuncertain. An uncertain or unfinished operation blocks further execution in that conversation until an operator reconciles it:confirm_nonexecution(id, decided_by=..., evidence=...)returns it to review, so it needs a new approval;abandon(id, decided_by=..., reason=...)stops it without claiming success.
- Every decision and reconciliation is kept in the record's
history.
OperationStore is a small protocol (get, put, records) so a service can
keep operations in its own database. put(record, expected_revision=...) is a
compare-and-set on the record's revision and raises OperationConflict when
the stored revision differs (None means the record must not exist yet); a
conflicting decide raises ValueError so the operator can reload and decide
again. FileOperationStore stores one fsynced JSON file per operation and is
safe within one process only. Pass authorizer= to OperationJournal to add
authorization sources such as study grants.
Artifacts¶
File-producing tools call chemgraph.runtime.register_artifact(path) after
writing. Outside a scoped invocation this does nothing.
Finalized tool messages carry artifact descriptors. All paths are relative to the workspace, so a workspace restored to another location still replays:
for artifact in result_artifacts(turn.state, task_id):
path = artifact_file(context.workspace, artifact)
# artifact: path, media_type, task_id, tool_call_id, source_path, sha256, size_bytes
Serve artifacts through the application's ownership checks rather than exposing the workspace. Back up the workspace, the operation store and the checkpoints together.