add: main.py — текстовая игра на основе llm + interrupt
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
import os
|
||||
import asyncio
|
||||
from typing import TypedDict, List, Annotated
|
||||
|
||||
from langgraph.graph import StateGraph, START, END
|
||||
from langgraph.checkpoint import InMemorySaver
|
||||
from langgraph.types import interrupt, Command
|
||||
from langgraph.graph.message import add_messages
|
||||
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langchain_core.messages import HumanMessage
|
||||
from langchain.tools import tool
|
||||
from deepagents import create_deep_agent
|
||||
from deepagents.backends import CompositeBackend, LocalShellBackend, FilesystemBackend
|
||||
|
||||
import questionary
|
||||
|
||||
# ---------- LLM ----------
|
||||
llm = ChatOpenAI(
|
||||
model="openai/gpt-oss-20b:free",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
api_key=os.getenv("OPENAI_API_KEY"),
|
||||
temperature=0.0,
|
||||
)
|
||||
|
||||
# ---------- Backend & Tools (required by deepagents) ----------
|
||||
backend = CompositeBackend(
|
||||
[
|
||||
LocalShellBackend(workspace_dir="./workspace"),
|
||||
FilesystemBackend(),
|
||||
]
|
||||
)
|
||||
|
||||
@tool
|
||||
def dummy_tool(query: str) -> str:
|
||||
"""A placeholder tool required by the deep agent."""
|
||||
return f"tool result for {query}"
|
||||
|
||||
agent = create_deep_agent(
|
||||
model=llm,
|
||||
tools=[dummy_tool],
|
||||
backend=backend,
|
||||
system_prompt="You are a helpful storytelling assistant.",
|
||||
)
|
||||
|
||||
# ---------- State ----------
|
||||
class StoryState(TypedDict):
|
||||
messages: Annotated[List, add_messages]
|
||||
topic: str
|
||||
intro: str
|
||||
options: List[str]
|
||||
choice: str
|
||||
ending: str
|
||||
|
||||
# ---------- Node ----------
|
||||
async def story_node(state: StoryState):
|
||||
# Phase 1: generate intro and options
|
||||
if "intro" not in state or not state["intro"]:
|
||||
prompt = (
|
||||
f"Topic: {state['topic']}. "
|
||||
"Create a short story beginning (2-3 sentences) and exactly three possible actions for the hero. "
|
||||
"Respond in the following format:\n"
|
||||
"Intro: <your text>\n"
|
||||
"Options:\n"
|
||||
"1) <first action>\n"
|
||||
"2) <second action>\n"
|
||||
"3) <third action>"
|
||||
)
|
||||
response = await agent.ainvoke(
|
||||
{"messages": [HumanMessage(content=prompt)]},
|
||||
{"configurable": {"thread_id": "agent-1"}},
|
||||
)
|
||||
text = response["messages"][-1].content
|
||||
|
||||
# Simple parsing
|
||||
intro_part, options_part = text.split("Options:", 1)
|
||||
intro = intro_part.replace("Intro:", "").strip()
|
||||
raw_options = options_part.strip().splitlines()
|
||||
options = [line.split(")", 1)[1].strip() for line in raw_options if ")" in line]
|
||||
|
||||
# Store and interrupt
|
||||
state["intro"] = intro
|
||||
state["options"] = options
|
||||
payload = {
|
||||
"type": "choice",
|
||||
"question": f"{intro}\n\nWhat does the hero do?",
|
||||
"options": options,
|
||||
}
|
||||
return interrupt(payload)
|
||||
|
||||
# Phase 2: after user choice, generate ending
|
||||
if "choice" in state and state["choice"]:
|
||||
prompt = (
|
||||
f"Intro: {state['intro']}\n"
|
||||
f"User choice: {state['choice']}\n"
|
||||
"Write a short conclusion (2-3 sentences) that follows from this choice."
|
||||
)
|
||||
response = await agent.ainvoke(
|
||||
{"messages": [HumanMessage(content=prompt)]},
|
||||
{"configurable": {"thread_id": "agent-2"}},
|
||||
)
|
||||
ending = response["messages"][-1].content.strip()
|
||||
state["ending"] = ending
|
||||
return state
|
||||
|
||||
# Should not reach here
|
||||
return state
|
||||
|
||||
# ---------- Graph ----------
|
||||
graph = StateGraph(StoryState)
|
||||
graph.add_node("story", story_node)
|
||||
graph.add_edge(START, "story")
|
||||
graph.add_edge("story", END)
|
||||
graph.set_entry_point("story")
|
||||
checkpointer = InMemorySaver()
|
||||
graph = graph.compile(checkpointer=checkpointer)
|
||||
|
||||
# ---------- Main loop ----------
|
||||
async def main():
|
||||
topic = questionary.text("Enter a story theme (e.g., 'space cat'):", default="space cat").ask()
|
||||
thread_id = "session-1"
|
||||
config = {"configurable": {"thread_id": thread_id}}
|
||||
|
||||
# Initial empty state
|
||||
initial_state: StoryState = {
|
||||
"messages": [],
|
||||
"topic": topic,
|
||||
"intro": "",
|
||||
"options": [],
|
||||
"choice": "",
|
||||
"ending": "",
|
||||
}
|
||||
|
||||
# First run - will pause at interrupt
|
||||
async for event in graph.stream(initial_state, config):
|
||||
if "__interrupt__" in event:
|
||||
payload = event["__interrupt__"][0].value
|
||||
question = payload["question"]
|
||||
choices = payload["options"]
|
||||
answer = questionary.select(question, choices=choices).ask()
|
||||
payload["choice"] = answer
|
||||
|
||||
# Resume graph with the updated payload
|
||||
resume_cmd = Command(resume=payload)
|
||||
async for resume_event in graph.stream(resume_cmd, config):
|
||||
if "__interrupt__" in resume_event:
|
||||
# No further interrupts expected
|
||||
continue
|
||||
# Continue until END
|
||||
break
|
||||
|
||||
# Retrieve final state
|
||||
final_state = await graph.aget_state(thread_id)
|
||||
print("\n--- Final Story ---")
|
||||
print(f"Theme: {final_state.state['topic']}\n")
|
||||
print(final_state.state["intro"])
|
||||
print(f"\nYou chose: {final_state.state['choice']}")
|
||||
print(f"\n{final_state.state['ending']}\n")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user