convy 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
convy/__init__.py ADDED
@@ -0,0 +1,53 @@
1
+ """convy: check conversational agents with a simulated user and a judge."""
2
+
3
+ from convy.agent import (
4
+ Agent,
5
+ AgentFailure,
6
+ Answer,
7
+ Conversation,
8
+ Message,
9
+ NoUsage,
10
+ TimeLimited,
11
+ Usage,
12
+ )
13
+ from convy.bench import Bench, Journal, JsonlJournal, RunHeader
14
+ from convy.dialog import NoVerdict, Transcript, Turn, Verdict
15
+ from convy.env import Env
16
+ from convy.http import HttpFailure, JsonAgent, JsonEndpoint, Tls
17
+ from convy.model import Model, ModelFailure, Models, OpenAiModel
18
+ from convy.report import Report, Runs
19
+ from convy.scenario import Matching, Outcome, Scenario, Scenarios
20
+
21
+ __all__ = [
22
+ "Agent",
23
+ "AgentFailure",
24
+ "Answer",
25
+ "Bench",
26
+ "Conversation",
27
+ "Env",
28
+ "HttpFailure",
29
+ "Journal",
30
+ "JsonAgent",
31
+ "JsonEndpoint",
32
+ "JsonlJournal",
33
+ "Matching",
34
+ "Message",
35
+ "Model",
36
+ "ModelFailure",
37
+ "Models",
38
+ "NoUsage",
39
+ "NoVerdict",
40
+ "OpenAiModel",
41
+ "Outcome",
42
+ "Report",
43
+ "RunHeader",
44
+ "Runs",
45
+ "Scenario",
46
+ "Scenarios",
47
+ "TimeLimited",
48
+ "Tls",
49
+ "Transcript",
50
+ "Turn",
51
+ "Usage",
52
+ "Verdict",
53
+ ]
convy/agent.py ADDED
@@ -0,0 +1,94 @@
1
+ """What convy needs from an agent, and the wrapper that limits the time of each of its steps."""
2
+
3
+ import asyncio
4
+ from collections.abc import AsyncIterator
5
+ from contextlib import AbstractAsyncContextManager, asynccontextmanager
6
+ from typing import Protocol
7
+
8
+ from msgspec import Struct
9
+
10
+
11
+ class Message(Struct, frozen=True):
12
+ """A message from the simulated user to the agent."""
13
+
14
+ text: str
15
+
16
+
17
+ class Usage(Struct, frozen=True, tag="usage"):
18
+ """Tokens the agent spent on one answer."""
19
+
20
+ input: int
21
+ output: int
22
+
23
+
24
+ class NoUsage(Struct, frozen=True, tag="no_usage"):
25
+ """The agent did not report the tokens it spent."""
26
+
27
+
28
+ class Answer(Struct, frozen=True):
29
+ """The agent's answer to a message."""
30
+
31
+ text: str
32
+ usage: Usage | NoUsage = NoUsage()
33
+
34
+
35
+ class AgentFailure(Exception):
36
+ """A turn of the agent failed."""
37
+
38
+
39
+ class Conversation(Protocol):
40
+ """One dialogue with an agent."""
41
+
42
+ async def answer(self, message: Message) -> Answer:
43
+ """Answer the message, or raise `AgentFailure` with the reason."""
44
+ ...
45
+
46
+
47
+ class Agent(Protocol):
48
+ """Anything convy can talk to."""
49
+
50
+ def conversation(self) -> AbstractAsyncContextManager[Conversation]:
51
+ """Open a new dialogue, independent of any other."""
52
+ ...
53
+
54
+
55
+ class TimeLimited(Struct, frozen=True):
56
+ """The same agent, with a limit on the time of each step: opening a conversation, a turn,
57
+ closing it."""
58
+
59
+ agent: Agent
60
+ seconds: float
61
+
62
+ @asynccontextmanager
63
+ async def conversation(self) -> AsyncIterator[Conversation]:
64
+ limit = asyncio.timeout(self.seconds)
65
+ try:
66
+ async with limit, self.agent.conversation() as conversation:
67
+ limit.reschedule(None) # turns have limits of their own
68
+ try:
69
+ yield LimitedConversation(conversation, self.seconds)
70
+ finally:
71
+ limit.reschedule(asyncio.get_running_loop().time() + self.seconds)
72
+ except TimeoutError:
73
+ if limit.expired():
74
+ raise AgentFailure(
75
+ f"the conversation did not open or close in {self.seconds:g} s"
76
+ ) from None
77
+ raise
78
+
79
+
80
+ class LimitedConversation(Struct, frozen=True):
81
+ """A conversation whose turns fail when they take longer than `seconds`."""
82
+
83
+ conversation: Conversation
84
+ seconds: float
85
+
86
+ async def answer(self, message: Message) -> Answer:
87
+ limit = asyncio.timeout(self.seconds)
88
+ try:
89
+ async with limit:
90
+ return await self.conversation.answer(message)
91
+ except TimeoutError:
92
+ if limit.expired():
93
+ raise AgentFailure(f"no answer in {self.seconds:g} s") from None
94
+ raise
convy/bench.py ADDED
@@ -0,0 +1,96 @@
1
+ """A run: every scenario played against an agent, each outcome written to a journal."""
2
+
3
+ import asyncio
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import Protocol
7
+
8
+ import msgspec
9
+ from msgspec import Struct
10
+
11
+ from convy.agent import Agent
12
+ from convy.model import Models
13
+ from convy.scenario import Outcome, Scenario
14
+
15
+
16
+ class RunHeader(Struct, frozen=True):
17
+ """What a run was: the agent and its version, the models, how many attempts were planned."""
18
+
19
+ agent: str
20
+ version: str
21
+ user: str
22
+ judge: str
23
+ attempts: int
24
+ planned: int
25
+ started: datetime
26
+
27
+
28
+ class Journal(Protocol):
29
+ """Where outcomes go as soon as they are ready."""
30
+
31
+ def record(self, outcome: Outcome) -> None: ...
32
+
33
+
34
+ class JsonlJournal(Struct, frozen=True):
35
+ """A run's file, `<directory>/<agent>/<start>.jsonl`: the header, then a line per attempt.
36
+
37
+ The file is created on the first `record`, so a run that never got an outcome leaves no file.
38
+ """
39
+
40
+ directory: Path
41
+ header: RunHeader
42
+
43
+ def path(self) -> Path:
44
+ name = self.header.started.strftime("%Y-%m-%dT%H-%M-%S.%f") # unique per run
45
+ return self.directory / self.header.agent / f"{name}.jsonl"
46
+
47
+ def record(self, outcome: Outcome) -> None:
48
+ path = self.path()
49
+ lines = [msgspec.json.encode(outcome)]
50
+ if not path.exists():
51
+ path.parent.mkdir(parents=True, exist_ok=True)
52
+ lines.insert(0, msgspec.json.encode(self.header))
53
+ with path.open("ab") as file:
54
+ file.write(b"".join(line + b"\n" for line in lines))
55
+
56
+
57
+ class Bench(Struct, frozen=True):
58
+ """Scenarios to play, the models that play the user and judge, and how to play them."""
59
+
60
+ scenarios: tuple[Scenario, ...]
61
+ models: Models
62
+ attempts: int = 1
63
+ parallel: int = 4
64
+
65
+ def __post_init__(self) -> None:
66
+ if self.attempts < 1:
67
+ raise ValueError(f"attempts must be at least 1, got {self.attempts}")
68
+ if self.parallel < 1:
69
+ raise ValueError(f"parallel must be at least 1, got {self.parallel}")
70
+
71
+ def planned(self) -> int:
72
+ return len(self.scenarios) * self.attempts
73
+
74
+ async def run(self, agent: Agent, journal: Journal) -> tuple[Outcome, ...]:
75
+ """Play every scenario `attempts` times, at most `parallel` conversations at once."""
76
+ limit = asyncio.Semaphore(self.parallel)
77
+ async with asyncio.TaskGroup() as group:
78
+ tasks = [
79
+ group.create_task(self.played(scenario, attempt, agent, journal, limit))
80
+ for scenario in self.scenarios
81
+ for attempt in range(1, self.attempts + 1)
82
+ ]
83
+ return tuple(task.result() for task in tasks)
84
+
85
+ async def played(
86
+ self,
87
+ scenario: Scenario,
88
+ attempt: int,
89
+ agent: Agent,
90
+ journal: Journal,
91
+ limit: asyncio.Semaphore,
92
+ ) -> Outcome:
93
+ async with limit:
94
+ outcome = await scenario.outcome(agent, self.models, attempt)
95
+ journal.record(outcome)
96
+ return outcome
convy/cli.py ADDED
@@ -0,0 +1,196 @@
1
+ """The `convy` command: `init`, `run` and `report`. It prints, and it picks the exit code."""
2
+
3
+ import asyncio
4
+ import sys
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+
8
+ from msgspec import Struct
9
+ from pydantic import BaseModel, Field, ValidationError
10
+ from pydantic_settings import (
11
+ BaseSettings,
12
+ CliApp,
13
+ CliPositionalArg,
14
+ CliSubCommand,
15
+ SettingsConfigDict,
16
+ )
17
+
18
+ from convy.agent import NoUsage, TimeLimited, Usage
19
+ from convy.bench import Bench, Journal, JsonlJournal, RunHeader
20
+ from convy.dialog import NoVerdict, Verdict
21
+ from convy.fakes import FakeModel, MemoryJournal
22
+ from convy.model import Models
23
+ from convy.project import Project, ProjectAgent
24
+ from convy.scenario import Matching, Outcome, Scenario
25
+
26
+ SMOKE = Scenario(
27
+ id="smoke",
28
+ max_turns=2,
29
+ instructions="Greet the assistant and ask what it can help with, then thank it.",
30
+ claims=("The agent answered the greeting",),
31
+ )
32
+
33
+
34
+ class Printed(Struct, frozen=True):
35
+ """A journal that also prints each outcome as it is recorded."""
36
+
37
+ journal: Journal
38
+
39
+ def record(self, outcome: Outcome) -> None:
40
+ self.journal.record(outcome)
41
+ match outcome.verdict:
42
+ case Verdict(passed=True):
43
+ mark = "✓"
44
+ case Verdict():
45
+ mark = "✗"
46
+ case NoVerdict():
47
+ mark = "?"
48
+ seconds = sum(turn.seconds for turn in outcome.transcript.turns)
49
+ print(f" {mark} {outcome.scenario} #{outcome.attempt} {seconds:.1f} s {outcome.stop}")
50
+
51
+
52
+ class Invalid(Struct, frozen=True):
53
+ """Settings that failed validation, as the command shows them: where and what, never the
54
+ values read, since they may be keys."""
55
+
56
+ error: ValidationError
57
+
58
+ def show(self) -> None:
59
+ for found in self.error.errors(include_input=False, include_url=False):
60
+ field = ".".join(str(part) for part in found["loc"])
61
+ print(f"error: {self.error.title}: {field}: {found['msg']}", file=sys.stderr)
62
+
63
+
64
+ class Rebuilt(Struct, frozen=True):
65
+ """A project's report, written again from its journals, as the command shows it."""
66
+
67
+ project: Project
68
+
69
+ def show(self) -> None:
70
+ for path in self.project.report():
71
+ print(
72
+ f"skipped unreadable lines or a whole journal: {self.project.runs() / path}",
73
+ file=sys.stderr,
74
+ )
75
+ print(f"report: {self.project.page()}")
76
+
77
+
78
+ class InitCommand(BaseModel):
79
+ path: CliPositionalArg[Path] = Path(".")
80
+
81
+ def cli_cmd(self) -> None:
82
+ created = Project(self.path).init()
83
+ for path in created:
84
+ print(f"created {path}")
85
+ print(
86
+ "nothing to create: every file exists"
87
+ if not created
88
+ else "next: convy run echo --smoke"
89
+ )
90
+
91
+
92
+ class RunCommand(BaseModel):
93
+ agents: CliPositionalArg[list[str]]
94
+ k: int = Field(1, ge=1, description="attempts per scenario")
95
+ scenarios: str = Field("*", description="only scenarios whose id matches this mask")
96
+ parallel: int = Field(4, ge=1, description="conversations at once")
97
+ turn_timeout: float = Field(
98
+ 600,
99
+ gt=0,
100
+ description="seconds for each step of the agent: opening a conversation, a turn, closing",
101
+ )
102
+ smoke: bool = Field(False, description="check the connection to the agent; no models called")
103
+
104
+ def cli_cmd(self) -> None:
105
+ project = Project(Path.cwd())
106
+ try:
107
+ agents = [project.agent(name) for name in self.agents]
108
+ benches = [self.bench(project) for _ in agents]
109
+ except ValidationError as error: # settings in the project's files
110
+ Invalid(error).show()
111
+ raise SystemExit(2) from None
112
+ except Exception as error: # anything else wrong in the project's files
113
+ print(f"error: {error}", file=sys.stderr)
114
+ raise SystemExit(2) from None
115
+ failed = False
116
+ for loaded, bench in zip(agents, benches, strict=True):
117
+ outcomes = asyncio.run(self.played(project, bench, loaded))
118
+ failed |= any(o.stop in ("agent_failure", "model_failure") for o in outcomes)
119
+ if not self.smoke:
120
+ Rebuilt(project).show()
121
+ raise SystemExit(int(failed))
122
+
123
+ def bench(self, project: Project) -> Bench:
124
+ """A bench for one agent, with models of its own: a fake model's replies run on across its
125
+ calls, and a project's models are built fresh by running `models.py`."""
126
+ if self.smoke:
127
+ user = FakeModel("Hello! What can you help me with?", "Thank you!")
128
+ return Bench((SMOKE,), Models(user, FakeModel('{"pass": true, "reason": "smoke"}')))
129
+ scenarios = tuple(Matching(project.scenarios(), self.scenarios))
130
+ return Bench(scenarios, project.models(), self.k, self.parallel)
131
+
132
+ async def played(
133
+ self, project: Project, bench: Bench, loaded: ProjectAgent
134
+ ) -> tuple[Outcome, ...]:
135
+ agent = TimeLimited(loaded.agent, self.turn_timeout)
136
+ if self.smoke:
137
+ print(f"{loaded.name}: checking the connection")
138
+ (outcome,) = await bench.run(agent, MemoryJournal())
139
+ self.smoked(outcome)
140
+ return (outcome,)
141
+ header = RunHeader(
142
+ agent=loaded.name,
143
+ version=loaded.version,
144
+ user=bench.models.user.name,
145
+ judge=bench.models.judge.name,
146
+ attempts=bench.attempts,
147
+ planned=bench.planned(),
148
+ started=datetime.now().astimezone(),
149
+ )
150
+ print(f"{loaded.name}: {len(bench.scenarios)} scenarios, {bench.attempts} attempts each")
151
+ return await bench.run(agent, Printed(JsonlJournal(project.runs(), header)))
152
+
153
+ def smoked(self, outcome: Outcome) -> None:
154
+ for turn in outcome.transcript.turns:
155
+ match turn.answer.usage:
156
+ case Usage(input=spent, output=produced):
157
+ tokens = f", {spent} / {produced} tokens"
158
+ case NoUsage():
159
+ tokens = ""
160
+ print(f" agent ({turn.seconds:.1f} s{tokens}): {turn.answer.text[:200]}")
161
+ match outcome:
162
+ case Outcome(stop="agent_failure", verdict=Verdict(reason=reason)):
163
+ print(f"connection failed: {reason}")
164
+ case _:
165
+ print("connection works")
166
+
167
+
168
+ class ReportCommand(BaseModel):
169
+ def cli_cmd(self) -> None:
170
+ Rebuilt(Project(Path.cwd())).show()
171
+
172
+
173
+ class Convy(BaseSettings):
174
+ """Check conversational agents with a simulated user and a judge."""
175
+
176
+ model_config = SettingsConfigDict(
177
+ cli_prog_name="convy",
178
+ cli_kebab_case=True,
179
+ cli_implicit_flags=True,
180
+ cli_enforce_required=True,
181
+ )
182
+ init: CliSubCommand[InitCommand] = Field(description="create a project with examples")
183
+ run: CliSubCommand[RunCommand] = Field(description="play scenarios against agents")
184
+ report: CliSubCommand[ReportCommand] = Field(description="rebuild results/index.html")
185
+
186
+ def cli_cmd(self) -> None:
187
+ CliApp.run_subcommand(self)
188
+
189
+
190
+ def main(argv: list[str] | None = None) -> None:
191
+ """The command's entry point."""
192
+ try:
193
+ CliApp.run(Convy, cli_args=argv) # None: sys.argv[1:]
194
+ except ValidationError as error:
195
+ Invalid(error).show()
196
+ raise SystemExit(2) from None
convy/dialog.py ADDED
@@ -0,0 +1,136 @@
1
+ """The dialogue: its record, the simulated user who drives it, and the judge who rates it."""
2
+
3
+ import json
4
+
5
+ from msgspec import Struct
6
+
7
+ from convy.agent import Answer, Message
8
+ from convy.model import Model, ModelFailure
9
+
10
+ STOP = "###STOP###"
11
+
12
+ OPENING = "(The assistant is waiting for your first message.)"
13
+
14
+ USER_PROMPT = """You are playing a user who writes to an assistant. Follow the instructions below.
15
+
16
+ Rules:
17
+ - Write one message at a time, as a real person does in a chat.
18
+ - Do not give away the whole of the instructions at once: say only what the current step needs.
19
+ - Do not make up facts that are not in the instructions. If you are asked about something they do
20
+ not cover, say that you do not know.
21
+ - Keep to the persona from the instructions until the end of the conversation.
22
+ - Write in the language of the instructions.
23
+ - When the goal is reached, or it is clear that it cannot be reached, answer exactly {stop}
24
+
25
+ Instructions:
26
+ {instructions}"""
27
+
28
+ JUDGE_PROMPT = """Below are a dialogue between a user and an agent, and a list of claims about it.
29
+ Check every claim strictly against the text of the dialogue.
30
+ Answer with JSON only: {{"pass": true or false, "reason": "a short explanation"}}
31
+ "pass" is true only if every claim holds. Write the reason in the language of the claims.
32
+
33
+ Dialogue:
34
+ {dialogue}
35
+
36
+ Claims:
37
+ {claims}"""
38
+
39
+
40
+ class Turn(Struct, frozen=True):
41
+ """A message, the agent's answer, and how many seconds the answer took."""
42
+
43
+ message: Message
44
+ answer: Answer
45
+ seconds: float
46
+
47
+
48
+ class Transcript(Struct, frozen=True):
49
+ """The record of a dialogue. It never changes: adding a turn gives a new transcript."""
50
+
51
+ turns: tuple[Turn, ...] = ()
52
+
53
+ def with_turn(self, turn: Turn) -> "Transcript":
54
+ return Transcript((*self.turns, turn))
55
+
56
+ def as_text(self) -> str:
57
+ """The dialogue as text, for the judge."""
58
+ return "\n".join(f"User: {t.message.text}\nAgent: {t.answer.text}" for t in self.turns)
59
+
60
+ def as_chat(self) -> list[dict[str, str]]:
61
+ """The dialogue for the simulated user: its messages are `assistant`, the agent's `user`."""
62
+ chat = []
63
+ for turn in self.turns:
64
+ chat.append({"role": "assistant", "content": turn.message.text})
65
+ chat.append({"role": "user", "content": turn.answer.text})
66
+ return chat
67
+
68
+
69
+ class Finished(Struct, frozen=True):
70
+ """The simulated user has nothing more to say."""
71
+
72
+
73
+ class SimulatedUser(Struct, frozen=True):
74
+ """A model that plays a person following a scenario's instructions."""
75
+
76
+ model: Model
77
+ instructions: str
78
+
79
+ async def next(self, transcript: Transcript) -> Message | Finished:
80
+ system = USER_PROMPT.format(stop=STOP, instructions=self.instructions)
81
+ messages = [
82
+ {"role": "system", "content": system},
83
+ {"role": "user", "content": OPENING},
84
+ *transcript.as_chat(),
85
+ ]
86
+ text = (await self.model.reply(messages)).strip()
87
+ if STOP in text:
88
+ return Finished()
89
+ if not text:
90
+ raise ModelFailure("the simulated user gave an empty message")
91
+ return Message(text)
92
+
93
+
94
+ class Verdict(Struct, frozen=True, tag="verdict"):
95
+ """The judge's decision on an attempt."""
96
+
97
+ passed: bool
98
+ reason: str
99
+
100
+
101
+ class NoVerdict(Struct, frozen=True, tag="no_verdict"):
102
+ """There is no verdict, because one of convy's models failed."""
103
+
104
+ error: str
105
+
106
+
107
+ class Judge(Struct, frozen=True):
108
+ """A model that decides whether a dialogue meets a scenario's claims."""
109
+
110
+ model: Model
111
+
112
+ async def verdict(self, transcript: Transcript, claims: tuple[str, ...]) -> Verdict:
113
+ prompt = JUDGE_PROMPT.format(
114
+ dialogue=transcript.as_text(), claims="\n".join(f"- {claim}" for claim in claims)
115
+ )
116
+ return self.parsed(await self.model.reply([{"role": "user", "content": prompt}]))
117
+
118
+ def parsed(self, text: str) -> Verdict:
119
+ """The first JSON object with "pass" in the judge's answer, even with text around it.
120
+
121
+ An answer without one, or with a "pass" that is not true or false, is a failure of the
122
+ judge's model, like no answer at all."""
123
+ decoder = json.JSONDecoder()
124
+ start = text.find("{")
125
+ while start != -1:
126
+ try:
127
+ found, end = decoder.raw_decode(text, start)
128
+ except json.JSONDecodeError:
129
+ start = text.find("{", start + 1)
130
+ continue
131
+ if isinstance(found, dict) and "pass" in found:
132
+ if type(found["pass"]) is not bool: # "true" or 1 is not a verdict
133
+ raise ModelFailure(f'the judge\'s "pass" is not true or false: {text[:200]}')
134
+ return Verdict(found["pass"], str(found.get("reason", "")))
135
+ start = text.find("{", end) # skip the whole object, nested braces included
136
+ raise ModelFailure(f"the judge did not answer with JSON: {text[:200]}")
convy/env.py ADDED
@@ -0,0 +1,16 @@
1
+ """Settings from `.env` and the environment."""
2
+
3
+ from pydantic_settings import BaseSettings, SettingsConfigDict
4
+
5
+
6
+ class Env(BaseSettings):
7
+ """The base class for settings in a project's `models.py` and `agents/*.py`.
8
+
9
+ It reads `.env` in the current directory and then the environment, ignores variables it does not
10
+ declare, and cannot be changed after it is read. Its errors do not show the values read, since
11
+ they may be keys.
12
+ """
13
+
14
+ model_config = SettingsConfigDict(
15
+ env_file=".env", extra="ignore", frozen=True, hide_input_in_errors=True
16
+ )
convy/fakes.py ADDED
@@ -0,0 +1,106 @@
1
+ """Simple working stand-ins for convy's interfaces, for tests: yours and convy's own."""
2
+
3
+ import asyncio
4
+ from collections.abc import AsyncIterator, Sequence
5
+ from contextlib import asynccontextmanager
6
+
7
+ from msgspec import Struct
8
+
9
+ from convy.agent import Answer, Conversation, Message, Usage
10
+ from convy.model import ModelFailure
11
+ from convy.scenario import Outcome
12
+
13
+ type Step = str | Answer | BaseException
14
+
15
+
16
+ class FakeAgent:
17
+ """An agent that answers from a list of steps.
18
+
19
+ A step is a string, an `Answer`, or an exception to raise on that turn. After the last step the
20
+ agent repeats it. Every conversation starts the list over. Each answer takes `delay` seconds.
21
+ `opened` counts the conversations opened, `peak` the most that were open at once.
22
+ """
23
+
24
+ def __init__(self, *steps: Step, delay: float = 0):
25
+ if not steps:
26
+ raise ValueError("FakeAgent needs at least one step")
27
+ self.steps = steps
28
+ self.delay = delay
29
+ self.opened = 0
30
+ self.peak = 0
31
+ self.open = 0
32
+
33
+ @asynccontextmanager
34
+ async def conversation(self) -> AsyncIterator[Conversation]:
35
+ self.opened += 1
36
+ self.open += 1
37
+ self.peak = max(self.peak, self.open)
38
+ try:
39
+ yield FakeConversation(self.steps, self.delay)
40
+ finally:
41
+ self.open -= 1
42
+
43
+
44
+ class FakeConversation:
45
+ """A conversation of `FakeAgent`."""
46
+
47
+ def __init__(self, steps: Sequence[Step], delay: float):
48
+ self.steps = steps
49
+ self.delay = delay
50
+ self.turns = 0
51
+
52
+ async def answer(self, message: Message) -> Answer:
53
+ step = self.steps[min(self.turns, len(self.steps) - 1)]
54
+ self.turns += 1
55
+ await asyncio.sleep(self.delay) # even at 0, other conversations get to run
56
+ if isinstance(step, BaseException):
57
+ raise step
58
+ return Answer(step) if isinstance(step, str) else step
59
+
60
+
61
+ class Echo(Struct, frozen=True):
62
+ """An agent that answers with the user's message; its tokens are the message's length."""
63
+
64
+ @asynccontextmanager
65
+ async def conversation(self) -> AsyncIterator[Conversation]:
66
+ yield EchoConversation()
67
+
68
+
69
+ class EchoConversation(Struct, frozen=True):
70
+ """A conversation of `Echo`."""
71
+
72
+ async def answer(self, message: Message) -> Answer:
73
+ length = len(message.text)
74
+ return Answer(message.text, Usage(input=length, output=length))
75
+
76
+
77
+ class FakeModel:
78
+ """A model that replies from a list: a step is a string, or an exception to raise.
79
+
80
+ The list runs across all calls, and the last step repeats. Its `name` is `"fake"`.
81
+ """
82
+
83
+ name = "fake"
84
+
85
+ def __init__(self, *steps: str | ModelFailure):
86
+ if not steps:
87
+ raise ValueError("FakeModel needs at least one step")
88
+ self.steps = steps
89
+ self.calls: list[list[dict[str, str]]] = []
90
+
91
+ async def reply(self, messages: list[dict[str, str]]) -> str:
92
+ step = self.steps[min(len(self.calls), len(self.steps) - 1)]
93
+ self.calls.append(list(messages))
94
+ if isinstance(step, ModelFailure):
95
+ raise step
96
+ return step
97
+
98
+
99
+ class MemoryJournal:
100
+ """A journal that keeps outcomes in memory, in `outcomes`."""
101
+
102
+ def __init__(self) -> None:
103
+ self.outcomes: list[Outcome] = []
104
+
105
+ def record(self, outcome: Outcome) -> None:
106
+ self.outcomes.append(outcome)