Source code for modelly.llm.agent

"""``LLMAgent``: a mixin that lets an agent ask the model's client and decide from the reply.

Each agent counts its own calls. The count and the agent's ``unique_id`` are in every
cache key it produces, so the keys do not depend on thread timing or on which other
agents asked first: a run on one thread and a run with many threads ask the cache for
the same keys. Agents have no memory of earlier calls in 1.3: each call sends the
persona and the prompt only.
"""

from __future__ import annotations

from collections.abc import Callable, Sequence
from typing import Any

from modelly.llm.client import Client
from modelly.llm.decide import DEFAULT_REPAIRS, decide_many, with_schema
from modelly.llm.request import Request

ATTRIBUTE = "llm"


def client_of(model: Any) -> Client:
    """The ``llm`` attribute of ``model``: the client its agents call."""
    client = getattr(model, ATTRIBUTE, None)
    if not isinstance(client, Client):
        raise AttributeError(
            "an LLMAgent calls model.llm: set self.llm = modelly.llm.Client(...) in the "
            "model's __init__"
        )
    return client


def model_seed(model: Any) -> int | None:
    """The seed the model's scenario derives its generators from, for cache keys."""
    scenario = getattr(model, "scenario", None)
    return getattr(scenario, "_stdlib_seed", None)


[docs] class LLMAgent: """Mix into an ``Agent`` (``class Voter(LLMAgent, Agent)``) to ask ``model.llm``. Attributes: persona: The system prompt of this agent's calls; the client's when empty. llm_calls: How many calls this agent made (including repairs). Example: >>> from modelly import Agent, Model >>> from modelly.llm import Client, FakeProvider >>> class Voter(LLMAgent, Agent): ... persona = "You are a careful voter." >>> model = Model(rng=1) >>> model.llm = Client(FakeProvider()) >>> Voter(model).ask("Vote?").startswith("fake reply") True """ persona: str = "" llm_calls: int = 0 @property def llm(self) -> Client: """The client of this agent's model.""" return client_of(self.model) # type: ignore[attr-defined] @property def llm_scope(self) -> str: """Who is asking, in cache keys and error messages.""" return f"{type(self).__name__}:{self.unique_id}" # type: ignore[attr-defined]
[docs] def next_call_index(self) -> int: """Take this agent's next call index.""" index = self.llm_calls self.llm_calls = index + 1 return index
[docs] def llm_request( self, prompt: str, *, schema: dict | None = None, **params: Any ) -> Request: """A request from this agent; with a schema, the prompt asks for JSON.""" text = with_schema(prompt, schema) if schema is not None else prompt return self.llm.request( text, scope=self.llm_scope, call_index=self.next_call_index(), system=self.persona or None, seed=model_seed(self.model), # type: ignore[attr-defined] schema=schema, **params, )
[docs] def ask(self, prompt: str, **params: Any) -> str: """The reply text to ``prompt``.""" return self.llm.complete_many([self.llm_request(prompt, **params)])[0].text
[docs] def decide( self, prompt: str, schema: dict, *, repairs: int = DEFAULT_REPAIRS, **params: Any, ) -> Any: """A decision matching ``schema``, with up to ``repairs`` repair calls. Raises: DecisionError: When the reply is still invalid after the repairs. """ return decide_all([self], prompt, schema, repairs=repairs, **params)[0]
[docs] def decide_all( agents: Sequence[LLMAgent], prompt: str | Callable[[Any], str], schema: dict, *, repairs: int = DEFAULT_REPAIRS, **params: Any, ) -> list[Any]: """One decision per agent, sent concurrently, returned in the order of ``agents``. Args: agents: ``LLMAgent`` instances of one model. prompt: The same prompt for all, or a function of the agent. schema: What each decision must match. repairs: Repair rounds for invalid replies. **params: Call parameters for every request. """ if not agents: return [] by_scope = {agent.llm_scope: agent for agent in agents} requests = [ agent.llm_request( prompt(agent) if callable(prompt) else prompt, schema=schema, **params ) for agent in agents ] client = agents[0].llm return decide_many( client, requests, lambda r: by_scope[r.scope].next_call_index(), repairs )