Reference

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,
)
ParameterTypeDefaultWhat it does
llmLLMrequiredThe PyWrapAI LLM instance to call.
enable_system_prompt_versioningboolFalseLoad a system prompt from the prompts directory.
prompt_filestrNoneFilename inside the prompts directory. Required when versioning is enabled.
prompts_dirstr"system_prompts_registry"Path to the prompts directory. Pass an absolute path in production.
retrieverBaseRetriever | NoneNoneIf set, enables automatic RAG context injection before every chat().
rag_kint4Maximum 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.tokensTokenTracker — same as graph.llm.tokens
graph.cacheResponseCache | None
graph.model / graph.providerstr — e.g. "claude-haiku-4-5-20251001" / "anthropic"
graph.system_promptstr | None — the loaded prompt text
graph.llmthe underlying LLM instance
graph.embedding_tokensTokenTracker | 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 missing

Tool 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 directly

Type hint → JSON Schema

Python typeJSON 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

Document

text + optional metadata dict. id auto-generates. The raw input before chunking.

Chunk

text, doc_id, metadata, embedding (None until embedded). The unit that gets stored and searched.

ScoredChunk

chunk + 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

ClassInstallInfraScalePersists
InMemoryVectorStorenumpy (always)None~100k chunksNo
SQLiteVectorStorestdlib onlySingle .db file~100k chunksYes
ChromaVectorStorepip install chromadbNone / DockerMillionsYes
FAISSVectorStorepip install faiss-cpuTwo local filesBillions (exact: ~1M)Yes
PGVectorStorepip install psycopg2-binary pgvectorPostgreSQL serverHundreds of millionsYes
QdrantVectorStorepip install qdrant-clientNone / Docker / CloudBillionsYes

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)

Use the following context to answer the question.
<context> [1] {chunk text} [2] {chunk text} </context>
Question: {original user message}

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

ParameterTypeDefaultWhat it does
max_turnsint10Stop after this many tool-calling turns.
tool_error_policystr"return_to_model""return_to_model" | "raise" | "skip" — see table below.
on_stepCallable | NoneNoneFires after each completed AgentStep. Raising inside it propagates out of run().
max_cost_usdfloat | NoneNoneHard USD cap per run — raises AgentBudgetExceededError.
max_input_tokensint | NoneNoneContext-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.
raiseLets the exception propagate immediately, aborting the run.
skipSilently 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

AgentStep

turn_num, tool_calls, tool_results, usage, cost_usd — one completed turn.

AgentResult

content, steps, total_turns, total_usage, total_cost_usd — the final return value on success.

AgentMaxTurnsError

Raised 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 limit
await 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.