PyWrapAI-Graph
Single responsibility: assemble the complete message list and call the LLM. System prompt, history, and RAG context all get injected here — the LLM only ever sees one fully assembled message list.
GraphLLM — constructor
graph = GraphLLM(
llm = llm,
enable_system_prompt_versioning = True,
prompt_file = "support-agent-v1.txt",
retriever = my_retriever, # optional — enables RAG
rag_k = 4,
)| Parameter | Type | Default | What it does |
|---|---|---|---|
llm | LLM | required | The PyWrapAI LLM instance to call. |
enable_system_prompt_versioning | bool | False | Load a system prompt from the prompts directory. |
prompt_file | str | None | Filename inside the prompts directory. Required when versioning is enabled. |
prompts_dir | str | "system_prompts_registry" | Path to the prompts directory. Pass an absolute path in production. |
retriever | BaseRetriever | None | None | If set, enables automatic RAG context injection before every chat(). |
rag_k | int | 4 | Maximum retrieved chunks to inject as context. |
If versioning is on but prompt_file is missing, raises ValueError at construction. If retriever is None, all RAG parameters are simply ignored.
Core methods
graph.chat(input, history=None, tools=None, tool_choice=None, rag=True)Assembles [system] + [history] + [current message] and calls LLM.chat(). RAG-augments the current message when a retriever is set and rag=True.
inputstr | list[Message]The current user message, or a list of messages.historylist[Message] | NonePast turns to inject — usually loaded by your persistence layer.ragboolSet False to skip retrieval for this call (a greeting, an admin message).Returns: LLMResponse — retrieved_chunks is set to the injected ScoredChunk list, or None if no retrieval happened.
response = graph.chat("What is the capital of France?")
if response.retrieved_chunks:
for sc in response.retrieved_chunks:
print(f"[{sc.score:.3f}] {sc.chunk.text[:80]}")graph.stream(input, history=None)Same assembly as chat(), delegates to LLM.stream(). Does not accept tools= — raises ValueError. Agent loops must use chat().
Returns: Iterator[str]
for chunk in graph.stream(input, history=history):
print(chunk, end="", flush=True)graph.chat_structured(input, schema, history=None)Identical to LLM.chat_structured() but with history and system prompt injection applied first.
Returns: A validated Pydantic model instance, or a parsed dict.
result = graph.chat_structured(input, schema=MyModel, history=history)await graph.achat(...) / async for chunk in graph.astream(...)Async versions of chat() and stream() — same assembly logic, same token tracking. achat() accepts tools=; astream() does not.
Returns: awaitable LLMResponse / AsyncIterator[str]
response = await graph.achat("Hello", history=history)
async for chunk in graph.astream("Write a story", history=history):
print(chunk, end="")Proxy properties
graph.tokens | TokenTracker — same as graph.llm.tokens |
graph.cache | ResponseCache | None |
graph.model / graph.provider | str — e.g. "claude-haiku-4-5-20251001" / "anthropic" |
graph.system_prompt | str | None — the loaded prompt text |
graph.llm | the underlying LLM instance |
graph.embedding_tokens | TokenTracker | None — the retriever's embedder tracker, or None if no retriever is set |
with_system_prompt()
Returns a shallow copy with a new system prompt — shares the same underlying LLM (same cache, same TokenTracker). The correct pattern for per-user or per-session prompts without mutating a shared graph.
base_graph = GraphLLM(llm=llm)
finance_graph = base_graph.with_system_prompt("You are a finance expert.")
support_graph = base_graph.with_system_prompt("You are a support agent.")
# All three share the same cache and TokenTracker
r1 = finance_graph.chat("What is the P/E ratio?")
r2 = support_graph.chat("How do I reset my password?")PromptRegistry
Used internally by GraphLLM. Files are plain .txt, recommended naming {role}-v{version}.txt for easy A/B testing.
registry = PromptRegistry(prompts_dir="/abs/path/to/system_prompts_registry")
text = registry.load("support-agent-v1.txt") # raises FileNotFoundError if missing
available = registry.list_files() # sorted list, [] if dir missingTool system
@tool reads a function's type hints and docstring at decoration time and builds the JSON Schema automatically — no hand-written schemas.
@tool
def get_weather(city: str, units: str = "celsius") -> str:
"""Get the current weather for a city."""
return f"16°C, cloudy in {city}"
# city → required (no default)
# units → not required (has a default)
get_weather(city="London") # still callable directlyType hint → JSON Schema
| Python type | JSON Schema type |
|---|---|
str | "string" |
int | "integer" |
float | "number" |
bool | "boolean" |
list | "array" |
dict | "object" |
no hint / unknown | "string" (safe default) |
ToolRegistry
register(item)Accepts a @tool-decorated function or a Tool instance. Plain functions raise TypeError immediately.
get(name)Returns the Tool; raises KeyError if not found.
execute(tool_call)Runs fn(**arguments), wraps the result in a ToolResult. Exceptions become "Error: ..." content instead of crashing.
to_schema()Provider-neutral format — always pass this to chat(tools=...). This is the one you use.
to_openai_schema() / to_anthropic_schema() / to_gemini_schema()Provider-specific formats — for inspection and testing only, never pass to chat().
Only the provider files know about wire-format differences — OpenAI wraps in {"type":"function",...}, Anthropic uses input_schema, Gemini builds FunctionDeclaration objects. Your code only ever calls to_schema().
RAG pipeline
Five composable primitives, each replaceable independently: Document → Chunker → embed → BaseVectorStore → VectorRetriever → GraphLLM(retriever=...).
Data types
Documenttext + optional metadata dict. id auto-generates. The raw input before chunking.
Chunktext, doc_id, metadata, embedding (None until embedded). The unit that gets stored and searched.
ScoredChunkchunk + score (cosine similarity, 0.0–1.0). What retrieve() and search() return, sorted descending.
Chunker
chunker = Chunker(chunk_size=500, overlap=50)
chunks: list[Chunk] = chunker.split(doc)Split priority: paragraph boundaries → sentence boundaries → hard character cut as a last resort. overlap preserves cross-boundary context between adjacent chunks.
Vector store backends
| Class | Install | Infra | Scale | Persists |
|---|---|---|---|---|
InMemoryVectorStore | numpy (always) | None | ~100k chunks | No |
SQLiteVectorStore | stdlib only | Single .db file | ~100k chunks | Yes |
ChromaVectorStore | pip install chromadb | None / Docker | Millions | Yes |
FAISSVectorStore | pip install faiss-cpu | Two local files | Billions (exact: ~1M) | Yes |
PGVectorStore | pip install psycopg2-binary pgvector | PostgreSQL server | Hundreds of millions | Yes |
QdrantVectorStore | pip install qdrant-client | None / Docker / Cloud | Billions | Yes |
All six implement BaseVectorStore — swap any one for another with no other code changes. Full constructor options for each are on the Vector Stores page.
VectorRetriever
retriever = VectorRetriever(embedder=embedder, store=store, k=4, min_score=0.0)
n_chunks = retriever.add_documents(docs, chunker=chunker) # chunk → embed → store, one batched call
results = retriever.retrieve("What is the capital of France?", k=3)
for sc in results:
print(f"[{sc.score:.3f}] {sc.chunk.text}")retrieval_tool()
t = retrieval_tool(retriever=retriever, name="search_knowledge_base",
description="Search the knowledge base for relevant facts.")
registry.register(t)
# The LLM can now call "search_knowledge_base" as a tool — the agent
# decides when to retrieve, rather than every call retrieving automatically.RAG context injection (D4 template)
Injected into the current message text, not a separate message — message count stays constant. Only the original query is ever persisted to history; the <context> block is stripped first, so follow-up turns always retrieve fresh context instead of stale chunks.
Agent — the ReAct loop
Calls the LLM with tool definitions, executes what it requests, appends results, and repeats until a plain-text answer or a limit is hit.
AgentConfig
| Parameter | Type | Default | What it does |
|---|---|---|---|
max_turns | int | 10 | Stop after this many tool-calling turns. |
tool_error_policy | str | "return_to_model" | "return_to_model" | "raise" | "skip" — see table below. |
on_step | Callable | None | None | Fires after each completed AgentStep. Raising inside it propagates out of run(). |
max_cost_usd | float | None | None | Hard USD cap per run — raises AgentBudgetExceededError. |
max_input_tokens | int | None | None | Context-window guard — raises AgentContextLimitError before overfilling. |
tool_error_policy
return_to_model (default) | Catches the exception, sends "Error: ..." as the tool result — the model sees it and can recover. |
raise | Lets the exception propagate immediately, aborting the run. |
skip | Silently drops the failed tool's result. Use with care. |
ToolNotFoundError always propagates regardless of policy. ToolArgumentError (bad arguments — validated before the function is even called) falls inside the policy, so the model can self-correct.
Result types
AgentStepturn_num, tool_calls, tool_results, usage, cost_usd — one completed turn.
AgentResultcontent, steps, total_turns, total_usage, total_cost_usd — the final return value on success.
AgentMaxTurnsErrorRaised at the turn limit. exc.partial is a full AgentResult with everything completed so far.
Running the loop
agent.run(input, history=None)Calls graph.chat(messages, tools=registry.to_schema(), rag=False) in a loop, executing tool calls and appending results until a plain-text answer or max_turns.
config = AgentConfig(max_turns=8, max_cost_usd=0.10)
agent = Agent(graph=graph, registry=registry, config=config)
try:
result = agent.run("What's the weather in London?")
print(result.content, result.total_cost_usd)
except AgentMaxTurnsError as exc:
print(exc.partial.total_cost_usd) # cost up to the limitawait agent.arun(input, history=None)Same loop using graph.achat(). Multiple tool calls in one turn run concurrently via asyncio.to_thread + asyncio.gather — not sequentially.
result = await agent.arun("Weather in London and Paris?")
print(result.total_cost_usd)Budget & context guards
max_cost_usd is checked after every LLM call — raises AgentBudgetExceededError with a .partial result. max_input_tokens is checked before each call with a 4-chars-per-token estimate, raising AgentContextLimitError before the provider would reject an overlong request.
Real concurrent tools
In arun(), multiple tool calls from one turn run via asyncio.to_thread + asyncio.gather — genuinely parallel across threads, not sequential coroutines masquerading as async.
Structured logging
logging.getLogger("pywrapai.agent") emits DEBUG per turn and tool call, INFO on completion, WARNING when max_turns is exhausted.