Skip to content

symfonic.kernel.tool_round

tool_round

Executing one round's calls, and saying so as it goes.

Split out of :mod:symfonic.kernel.runner when that module reached its size budget. The concept is a real one rather than a place to put lines: a round is the unit that runs a batch of reserved calls and emits a tool_call before each and a tool_result after it, so a consumer of the event stream sees the same interleaving the tool port saw.

The outcomes come back in request order. record_tools and the post-tool rung both join results to calls positionally, so a round that returned them in completion order would attach a result to the wrong call and nothing downstream could tell.

And it is where a precondition gets to object. plan.bindings.tool_preconditions are checks a capability contributed, and this is the one point the kernel dispatches a call -- so a check here judges the call that is about to run: after reserve_requests assigned its id and after every pre-tool stage has amended it. Judging earlier would judge the call the model asked for, and an amendment could then walk a refused call past the rule that refused it.

Three answers, not two. A check may object, in which case the port is asked for a refusal and the tool is never invoked; it may admit; or it may declare that the rules themselves could not be reached. The third admits and reports that no decision was made, so a stage event says the check did not happen -- a degradation that looked like an admission would be a gate reporting success for a question it never asked.

A check that raises does not get the third answer. An enforcement fault must not become a permission: a malformed predicate, an evaluator that threw, a state the rule cannot read -- each is a gate that failed while a rule existed, and admitting on them turns a broken gate into an open one. The narrow case the third answer covers is that the store of rules was unreachable, which the check declares with PreconditionVerdict.unavailable; only the check knows which of the two happened, so only the check may say.

run_tool_round async

run_tool_round(plan: Any, requests: Sequence[Any], *, emitter: Any, publish: Callable[[Any], Any], lifecycle: Any, outcomes: list[Any]) -> AsyncIterator[Any]

Run requests in order, yielding each call and its result.

outcomes is filled rather than returned because this is a generator: the caller needs the results after the stream is drained, and an async generator cannot hand back a value the way a coroutine can.

Source code in src/symfonic/kernel/tool_round.py
async def run_tool_round(
    plan: Any,
    requests: Sequence[Any],
    *,
    emitter: Any,
    publish: Callable[[Any], Any],
    lifecycle: Any,
    outcomes: list[Any],
) -> AsyncIterator[Any]:
    """Run ``requests`` in order, yielding each call and its result.

    ``outcomes`` is filled rather than returned because this is a generator:
    the caller needs the results after the stream is drained, and an async
    generator cannot hand back a value the way a coroutine can.
    """
    checks = tuple(getattr(plan.bindings, "tool_preconditions", ()) or ())
    for tool_request in requests:
        yield await publish(emitter.make("tool_call", tool_request=tool_request))

        objection, undetermined = await _judge(
            checks, tool_request, lifecycle=lifecycle
        )
        if checks:
            yield await publish(
                _diagnostic(emitter, checks, objection, undetermined)
            )

        if objection is not None:
            # Refused by the port, so the outcome is the same shape a run
            # produces and the facade keeps ownership of its own type. The
            # tool is not invoked: not called with a flag, not called and
            # discarded -- not called.
            outcome = plan.bindings.tools.refuse(
                tool_request, str(getattr(objection, "content", "") or "")
            )
        else:
            outcome = await lifecycle.bounded(
                plan.bindings.tools.execute(tool_request),
                doing=f"executing tool {tool_request.name!r}",
            )
        yield await publish(emitter.make("tool_result", tool_outcome=outcome))
        outcomes.append(outcome)