Skip to content

chatomon.engine

The orbital-chain engine: runs orbitals and turns verdicts into decisions.

The engine is pure Python and framework-agnostic. A connector calls it from the framework's native hooks, in this order for every turn:

  1. start_turn;
  2. run_input on the user message;
  3. unless the input decision is reply, run_output on every final reply of the nucleus, until the decision is not regenerate;
  4. finish_turn.

Classes:

Name Description
TurnState

Mutable state of one turn; the connector keeps it between its hooks.

Engine

Runs an atom's orbital chains; created by Atom.build().

Attributes:

Name Type Description
TurnCallback

Sync or async function that receives the report of every turn.

TurnCallback module-attribute

TurnCallback = Callable[
    [TurnReport], Awaitable[None] | None
]

Sync or async function that receives the report of every turn.

TurnState dataclass

TurnState(
    turn_id: str,
    conversation_id: str | None,
    started_at: datetime,
    started_perf: float,
    annotations: dict[str, Any] = dict(),
    output_guidance: list[str] = list(),
    input_annotations: dict[str, Any] = dict(),
    input_results: list[OrbitalResult] = list(),
    output_attempts: list[_Attempt] = list(),
    input_decision: InputDecision | None = None,
    attempt: int = 0,
    user_message: str | None = None,
    final_reply: str | None = None,
    final_action: FinalAction | None = None,
    escalated: bool = False,
    finished: bool = False,
)

Mutable state of one turn; the connector keeps it between its hooks.

Attributes:

Name Type Description
turn_id str

Identifier of the turn.

conversation_id str | None

Identifier of the conversation, when available.

started_at datetime

When the turn started (UTC).

started_perf float

time.perf_counter() at the start of the turn.

annotations dict[str, Any]

Annotations produced so far, by orbital name.

output_guidance list[str]

Guidance of every output block with guidance so far; each regeneration gets all of it.

input_annotations dict[str, Any]

Annotations left by the input chain; every output attempt starts from them.

input_results list[OrbitalResult]

Audit records of the input chain.

output_attempts list[_Attempt]

The attempts of the output chain, one per reply it checked; the last one has

input_decision InputDecision | None

The decision of the input chain, once it ran.

attempt int

Number of output-chain runs so far.

user_message str | None

The user message of this turn.

final_reply str | None

The reply sent to the user, once known.

final_action FinalAction | None

How the turn ended, once known.

escalated bool

Whether any orbital asked for a human.

finished bool

Whether the report was produced.

turn_id instance-attribute

turn_id: str

Identifier of the turn.

conversation_id instance-attribute

conversation_id: str | None

Identifier of the conversation, when available.

started_at instance-attribute

started_at: datetime

When the turn started (UTC).

started_perf instance-attribute

started_perf: float

time.perf_counter() at the start of the turn.

annotations class-attribute instance-attribute

annotations: dict[str, Any] = field(default_factory=dict)

Annotations produced so far, by orbital name.

output_guidance class-attribute instance-attribute

output_guidance: list[str] = field(default_factory=list)

Guidance of every output block with guidance so far; each regeneration gets all of it.

input_annotations class-attribute instance-attribute

input_annotations: dict[str, Any] = field(
    default_factory=dict
)

Annotations left by the input chain; every output attempt starts from them.

input_results class-attribute instance-attribute

input_results: list[OrbitalResult] = field(
    default_factory=list
)

Audit records of the input chain.

output_attempts class-attribute instance-attribute

output_attempts: list[_Attempt] = field(
    default_factory=list
)

The attempts of the output chain, one per reply it checked; the last one has no decision while the chain runs.

input_decision class-attribute instance-attribute

input_decision: InputDecision | None = None

The decision of the input chain, once it ran.

attempt class-attribute instance-attribute

attempt: int = 0

Number of output-chain runs so far.

user_message class-attribute instance-attribute

user_message: str | None = None

The user message of this turn.

final_reply class-attribute instance-attribute

final_reply: str | None = None

The reply sent to the user, once known.

final_action class-attribute instance-attribute

final_action: FinalAction | None = None

How the turn ended, once known.

escalated class-attribute instance-attribute

escalated: bool = False

Whether any orbital asked for a human.

finished class-attribute instance-attribute

finished: bool = False

Whether the report was produced.

Engine

Engine(
    *,
    atom_name: str,
    input_orbitals: Sequence[Orbital] = (),
    output_orbitals: Sequence[Orbital] = (),
    refusal: str = DEFAULT_REFUSAL,
    blocked_guidance: str | None = DEFAULT_BLOCKED_GUIDANCE,
    max_retries: int = 2,
    on_turn: TurnCallback | None = None,
    content: ContentPolicy | None = None,
    tracer: Tracer | None = None
)

Runs an atom's orbital chains; created by Atom.build().

Parameters:

Name Type Description Default
atom_name str

Name of the atom, used in spans, logs and reports.

required
input_orbitals Sequence[Orbital]

Orbitals run in order on the user message.

()
output_orbitals Sequence[Orbital]

Orbitals run in order on the nucleus reply.

()
refusal str

Reply used when a message is blocked without a specific reply.

DEFAULT_REFUSAL
blocked_guidance str | None

Guidance given to the nucleus, before the orbital's own, when it writes the refusal of a blocked message. None gives it the orbital's guidance alone.

DEFAULT_BLOCKED_GUIDANCE
max_retries int

How many times the nucleus may regenerate a reply blocked with guidance before the refusal is used.

2
on_turn TurnCallback | None

Receives the report of every turn.

None
content ContentPolicy | None

Content-capture policy; records nothing when None.

None
tracer Tracer | None

OpenTelemetry tracer; chatomon's global tracer when None.

None

Raises:

Type Description
ChatomonConfigError

If max_retries is negative.

Examples:

Connectors drive the engine; this is the sequence they follow for one turn:

import asyncio

from chatomon import OrbitalContext, Verdict, orbital
from chatomon.engine import Engine


@orbital
def no_refunds(ctx: OrbitalContext) -> Verdict:
    if "refund" in ctx.message.lower():
        return Verdict.block(guidance="Explain that refunds are handled by email.")
    return Verdict.allow()


async def main() -> None:
    engine = Engine(atom_name="demo", input_orbitals=[no_refunds])
    turn = engine.start_turn()
    decision = await engine.run_input(turn, message="I want a refund")
    assert decision.kind == "guided_refusal"
    await engine.run_output(turn, reply="Refunds are handled by email: write to us.")
    report = await engine.finish_turn(turn)
    assert report.final_action == "guided_refusal"
    assert [attempt.decision for attempt in report.output] == ["accept"]


asyncio.run(main())

Methods:

Name Description
start_turn

Start a new turn.

run_input

Run the input chain on the user message.

run_output

Run the output chain on a final reply of the nucleus.

reopen_output

Allow a new reply to be checked after the turn already had one.

finish_turn

Close the turn, build its report and deliver it to on_turn.

abort_turn

Close a turn that the nucleus or the framework could not complete.

Attributes:

Name Type Description
atom_name str

Name of the atom.

input_orbitals tuple[Orbital, ...]

The input chain.

output_orbitals tuple[Orbital, ...]

The output chain.

refusal str

The default refusal.

max_retries int

Maximum number of regenerations per turn.

content ContentPolicy

The content-capture policy.

Source code in src/chatomon/engine.py
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
def __init__(
    self,
    *,
    atom_name: str,
    input_orbitals: Sequence[Orbital] = (),
    output_orbitals: Sequence[Orbital] = (),
    refusal: str = DEFAULT_REFUSAL,
    blocked_guidance: str | None = DEFAULT_BLOCKED_GUIDANCE,
    max_retries: int = 2,
    on_turn: TurnCallback | None = None,
    content: ContentPolicy | None = None,
    tracer: trace.Tracer | None = None,
) -> None:
    if max_retries < 0:
        raise ChatomonConfigError(f"max_retries must be >= 0, got {max_retries!r}")
    self._atom_name = atom_name
    self._input = tuple(input_orbitals)
    self._output = tuple(output_orbitals)
    self._refusal = refusal
    self._blocked_guidance = blocked_guidance
    self._max_retries = max_retries
    self._on_turn = on_turn
    self._content = content if content is not None else ContentPolicy()
    self._tracer = tracer if tracer is not None else get_tracer()
    self._fingerprints = {id(orb): orb.fingerprint() for orb in (*self._input, *self._output)}

atom_name property

atom_name: str

Name of the atom.

input_orbitals property

input_orbitals: tuple[Orbital, ...]

The input chain.

output_orbitals property

output_orbitals: tuple[Orbital, ...]

The output chain.

refusal property

refusal: str

The default refusal.

max_retries property

max_retries: int

Maximum number of regenerations per turn.

content property

content: ContentPolicy

The content-capture policy.

start_turn

start_turn(
    *, conversation_id: str | None = None
) -> TurnState

Start a new turn.

Parameters:

Name Type Description Default
conversation_id str | None

Identifier of the conversation, when the framework has one.

None

Returns:

Type Description
TurnState

The state the connector passes to the other methods.

Source code in src/chatomon/engine.py
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
def start_turn(self, *, conversation_id: str | None = None) -> TurnState:
    """Start a new turn.

    Args:
        conversation_id: Identifier of the conversation, when the framework has one.

    Returns:
        The state the connector passes to the other methods.
    """
    return TurnState(
        turn_id=uuid.uuid4().hex,
        conversation_id=conversation_id,
        started_at=datetime.now(UTC),
        started_perf=time.perf_counter(),
    )

run_input async

run_input(
    turn: TurnState,
    *,
    message: str,
    history: Sequence[Message] = ()
) -> InputDecision

Run the input chain on the user message.

Parameters:

Name Type Description Default
turn TurnState

The turn started with start_turn.

required
message str

The user message.

required
history Sequence[Message]

Earlier messages, with blocked user messages already replaced by the placeholder.

()

Returns:

Type Description
InputDecision

What the connector must do next.

Raises:

Type Description
RuntimeError

If the turn is finished or the input chain already ran.

Source code in src/chatomon/engine.py
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
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
async def run_input(
    self, turn: TurnState, *, message: str, history: Sequence[Message] = ()
) -> InputDecision:
    """Run the input chain on the user message.

    Args:
        turn: The turn started with `start_turn`.
        message: The user message.
        history: Earlier messages, with blocked user messages already replaced
            by the placeholder.

    Returns:
        What the connector must do next.

    Raises:
        RuntimeError: If the turn is finished or the input chain already ran.
    """
    if turn.finished:
        raise RuntimeError("this turn is already finished")
    if turn.input_decision is not None:
        raise RuntimeError("run_input was already called for this turn")
    turn.user_message = message
    guidance: list[str] = []
    decision: InputDecision | None = None
    with self._tracer.start_as_current_span(
        "chatomon.input", attributes=self._turn_attributes(turn)
    ) as span:
        for orb in self._input:
            verdict = await self._run_orbital(
                orb,
                turn,
                side="input",
                message=message,
                history=history,
                attempt=1,
                results=turn.input_results,
            )
            if verdict.action == "allow":
                if verdict.guidance:
                    guidance.append(verdict.guidance)
                continue
            decision = self._stop_input(turn, verdict)
            break
        if decision is None:
            decision = InputDecision(kind="proceed", guidance=tuple(guidance))
        span.set_attribute("chatomon.decision.kind", decision.kind)
    turn.input_decision = decision
    turn.input_annotations = dict(turn.annotations)
    self._log_decision(turn, "input", decision.kind)
    return decision

run_output async

run_output(
    turn: TurnState,
    *,
    reply: str,
    history: Sequence[Message] = ()
) -> OutputDecision

Run the output chain on a final reply of the nucleus.

Call it on every final reply until the decision is not regenerate. Every call is one attempt of the turn's report, with the reply recorded according to capture_content.

Parameters:

Name Type Description Default
turn TurnState

The turn whose input chain already ran.

required
reply str

The nucleus reply.

required
history Sequence[Message]

Earlier messages including the current user message (replaced by the placeholder if the input decision asked for it).

()

Returns:

Type Description
OutputDecision

What the connector must do with the reply.

Raises:

Type Description
RuntimeError

If the input chain did not run, already produced the reply, or the turn already has its final reply.

Source code in src/chatomon/engine.py
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
async def run_output(
    self, turn: TurnState, *, reply: str, history: Sequence[Message] = ()
) -> OutputDecision:
    """Run the output chain on a final reply of the nucleus.

    Call it on every final reply until the decision is not `regenerate`. Every
    call is one attempt of the turn's report, with the reply recorded according
    to `capture_content`.

    Args:
        turn: The turn whose input chain already ran.
        reply: The nucleus reply.
        history: Earlier messages including the current user message
            (replaced by the placeholder if the input decision asked for it).

    Returns:
        What the connector must do with the reply.

    Raises:
        RuntimeError: If the input chain did not run, already produced the
            reply, or the turn already has its final reply.
    """
    if turn.finished:
        raise RuntimeError("this turn is already finished")
    if turn.input_decision is None:
        raise RuntimeError("call run_input before run_output")
    if turn.input_decision.kind == "reply":
        raise RuntimeError(
            "the input chain already produced the reply; run_output must not be called"
        )
    if turn.final_action is not None:
        raise RuntimeError("this turn already has its final reply")
    turn.attempt += 1
    attempt = turn.attempt
    turn.annotations = dict(turn.input_annotations)
    # In the state before the chain runs, with the content policy applied: a turn
    # closed while an orbital runs reports the attempt with the results it has.
    record = _Attempt(nucleus_reply=self._content.text(reply))
    turn.output_attempts.append(record)
    decision: OutputDecision | None = None
    with self._tracer.start_as_current_span(
        "chatomon.output",
        attributes={**self._turn_attributes(turn), "chatomon.attempt": attempt},
    ) as span:
        for orb in self._output:
            verdict = await self._run_orbital(
                orb,
                turn,
                side="output",
                message=reply,
                history=history,
                attempt=attempt,
                results=record.results,
            )
            if verdict.action == "allow":
                continue
            decision = self._stop_output(turn, verdict, attempt)
            break
        if decision is None:
            decision = OutputDecision(kind="accept")
            turn.final_reply = reply
            turn.final_action = (
                "guided_refusal" if turn.input_decision.kind == "guided_refusal" else "answered"
            )
        span.set_attribute("chatomon.decision.kind", decision.kind)
    record.decision = decision.kind
    self._log_decision(turn, "output", decision.kind)
    return decision

reopen_output

reopen_output(turn: TurnState) -> None

Allow a new reply to be checked after the turn already had one.

Connectors call it when the framework asks the model again after chatomon accepted or replaced a reply (for example a PydanticAI output validator raising ModelRetry). The next run_output counts as a new attempt.

Parameters:

Name Type Description Default
turn TurnState

The turn to reopen.

required

Raises:

Type Description
RuntimeError

If the turn is finished or its reply came from the input chain.

Source code in src/chatomon/engine.py
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
def reopen_output(self, turn: TurnState) -> None:
    """Allow a new reply to be checked after the turn already had one.

    Connectors call it when the framework asks the model again after chatomon
    accepted or replaced a reply (for example a PydanticAI output validator
    raising `ModelRetry`). The next `run_output` counts as a new attempt.

    Args:
        turn: The turn to reopen.

    Raises:
        RuntimeError: If the turn is finished or its reply came from the input chain.
    """
    if turn.finished:
        raise RuntimeError("this turn is already finished")
    if turn.input_decision is None or turn.input_decision.kind == "reply":
        raise RuntimeError("the input chain produced the reply; there is no output to reopen")
    turn.final_action = None
    turn.final_reply = None

finish_turn async

finish_turn(turn: TurnState) -> TurnReport

Close the turn, build its report and deliver it to on_turn.

An exception raised by on_turn is logged and never propagates.

Parameters:

Name Type Description Default
turn TurnState

The turn, which must have its final reply.

required

Returns:

Type Description
TurnReport

The audit report of the turn.

Raises:

Type Description
RuntimeError

If the turn has no final reply yet or was already finished.

Source code in src/chatomon/engine.py
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
async def finish_turn(self, turn: TurnState) -> TurnReport:
    """Close the turn, build its report and deliver it to `on_turn`.

    An exception raised by `on_turn` is logged and never propagates.

    Args:
        turn: The turn, which must have its final reply.

    Returns:
        The audit report of the turn.

    Raises:
        RuntimeError: If the turn has no final reply yet or was already finished.
    """
    if turn.finished:
        raise RuntimeError("finish_turn was already called for this turn")
    if turn.final_action is None:
        raise RuntimeError("the turn has no final reply yet")
    return await self._close(turn, turn.final_action)

abort_turn async

abort_turn(
    turn: TurnState, error: BaseException
) -> TurnReport

Close a turn that the nucleus or the framework could not complete.

Connectors call it when the model call or the framework raises, or the run is cancelled, so every turn leaves an audit record. The exception type is always recorded; its message only with capture_content="full".

Parameters:

Name Type Description Default
turn TurnState

The turn to close, at any stage.

required
error BaseException

The exception that ended the turn.

required

Returns:

Type Description
TurnReport

The audit report, with final_action="aborted".

Raises:

Type Description
RuntimeError

If the turn was already finished.

Source code in src/chatomon/engine.py
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
async def abort_turn(self, turn: TurnState, error: BaseException) -> TurnReport:
    """Close a turn that the nucleus or the framework could not complete.

    Connectors call it when the model call or the framework raises, or the run
    is cancelled, so every turn leaves an audit record. The exception type is
    always recorded; its message only with `capture_content="full"`.

    Args:
        turn: The turn to close, at any stage.
        error: The exception that ended the turn.

    Returns:
        The audit report, with `final_action="aborted"`.

    Raises:
        RuntimeError: If the turn was already finished.
    """
    if turn.finished:
        raise RuntimeError("this turn was already finished")
    turn.final_reply = None
    return await self._close(turn, "aborted", error)