chat¶
Source: AGL-workflows/chat
chat has Haiku, through Claude Code, and Luna, through Codex, talk about a topic you give it. They
run at the same time and take turns saying one line each, and the terminal shows the chat as it
goes. The chat ends after 20 lines, or as many as -l gives.
Get it and run it¶
Download from AGL-workflows repository:
How it's built¶
__init__.py holds the workflow, roles.py its two roles and their tools, display.py what it
shows in the terminal, and prompts/ a prompt for each role.
Roles¶
haiku_speaker runs Haiku through Claude Code, and luna_speaker runs Luna through Codex. Each
gets its say and listen from the Conversation it is given. Neither has a reporting tool, so
each step returns None.
@role(model=Claude.HAIKU(effort=ClaudeEffort.LOW), accepts=(str,))
def haiku_speaker(conversation: Conversation) -> Role[None]:
return Role(
name="haiku",
instructions=prompt_file("prompts/haiku.md"),
tools=conversation.tools(HAIKU),
)
@role(model=OpenAI.LUNA(effort=OpenAIEffort.LOW), accepts=(str,))
def luna_speaker(conversation: Conversation) -> Role[None]:
return Role(
name="luna",
instructions=prompt_file("prompts/luna.md"),
tools=conversation.tools(LUNA),
)
See Model and effort.
Conversation¶
One Conversation holds the chat for both sides: its lines, whether it is over, and turn, an
event for each side that is set while it is that side's turn. Its tools() builds a side's say,
which takes a Line, and its listen, which takes no arguments.
@dataclass(frozen=True, slots=True)
class Line:
message: str = describe(
"your next line in the chat: 200 characters at most, no name in front of it"
)
...
def tools(self, name: str) -> tuple[Tool, Tool]:
say = tool(
"say",
"Say your next line, which is how the other one hears you.",
Line,
lambda line: self.say(name, line.message),
)
listen = Tool(
name="listen",
description="Wait for the other one to say something, and read it. Takes no arguments.",
payload_schema={"type": "object", "properties": {}},
handler=lambda _: self.listen(name),
)
return say, listen
Say tool¶
say returns rejected=True once the chat is over, or when it is not the side's turn. Otherwise it
ends the side's turn, adds the line to lines and to the board, and hands the turn to the other
side, which wakes its listen. The line that reaches the limit ends the chat instead.
async def say(self, name: str, message: str) -> ToolResult:
if self.over:
return ToolResult(text=OVER, rejected=True)
if not self.turn[name].is_set():
return ToolResult(text=NOT_YOUR_TURN, rejected=True)
self.turn[name].clear()
self.lines.append(f"{name}: {message}")
said(COLOUR[name], name, message)
if len(self.lines) >= self.limit:
self.end()
return ToolResult(text=OVER)
self._hand_to(_other(name))
return ToolResult(text="Said. Now listen for the answer.")
See Tools.
Listen tool¶
listen waits for the side's event, then returns the other side's last line, or tells the agent the
chat is over. Haiku opens, so its listen before the first line tells it to speak. end marks the
chat over and sets both events, so no listen is left waiting.
async def listen(self, name: str) -> ToolResult:
await self.turn[name].wait()
if self.over:
return ToolResult(text=OVER)
if not self.lines:
return ToolResult(text=OPENING)
return ToolResult(text=self.lines[-1])
def end(self) -> None:
self.over = True
quiet()
for turn in self.turn.values():
turn.set()
See ToolResult.
Terminal display¶
The workflow shows board with opened, and the terminal redraws it on every frame. say adds
each line to spoken through said, and puts the side that speaks next in thinking through
waiting. board draws each line after its speaker's name in colour, a spinner for the side in
thinking, and drops the oldest rows once the chat is taller than the terminal.
spoken: list[tuple[str, str, str]] = []
thinking: list[tuple[str, str]] = []
async def opened(terminal: Terminal, header: str) -> None:
await terminal.show(board, header=header)
...
def board(*, header: str) -> Screen:
width, height = shutil.get_terminal_size()
blocks = [_rows(*line, width) for line in spoken] + [_spinning()]
# A blank row ahead of every block but the first, so one turn is easy to tell from the next
drawn = [row for block in blocks if block for row in (Row(""), *block)][1:]
# A board taller than the terminal has its last rows cropped away, and the last row is the
# line that just landed - so the older end of the chat is what goes, and it scrolls nowhere
room = max(height - len(PADDING) - 1, 1)
return Screen(Rows([Row(header), *PADDING, *drawn[-room:]]))
See Run.terminal.
Two agents at once¶
The workflow makes one Conversation with the --lines limit, then runs both steps at once in an
asyncio.TaskGroup, each in a worktree named after its role and with no commit. When a step ends,
end tells the other side the chat is over. When both steps have returned, the workflow raises
Stop with the number of lines said.
@workflow
async def chat(run: Run[Parameters]) -> None:
await opened(run.terminal, f"{HAIKU} and {LUNA}'s chat")
conversation = Conversation(run.params.lines)
async with asyncio.TaskGroup() as group:
group.create_task(talking(run, conversation, haiku_speaker(conversation)))
group.create_task(talking(run, conversation, luna_speaker(conversation)))
raise Stop(
f"the chat ended after {len(conversation.lines)} of the {conversation.limit} lines it is "
f"capped at"
)
async def talking(run: Run[Parameters], conversation: Conversation, speaking: Role[None]) -> None:
try:
await run.worktree(speaking.name).step(speaking, run.params.topic)
finally:
conversation.end()
See Run.step(), Run.worktree() and
Stop.
Build your own¶
- To have other models talk, change each role's model and effort, and the model names in its prompt.
- To give the chat more than a topic, add a type to each role's
acceptsand a placeholder to its prompt. - To get a result back from the chat, give a role a
reporting tool, have its prompt tell the
agent to call it, and return the step's result from
talking.