virtus 0.0.3__tar.gz

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.
Files changed (42) hide show
  1. virtus-0.0.3/.gitignore +49 -0
  2. virtus-0.0.3/LICENSE.md +21 -0
  3. virtus-0.0.3/PKG-INFO +547 -0
  4. virtus-0.0.3/README.md +522 -0
  5. virtus-0.0.3/pyproject.toml +80 -0
  6. virtus-0.0.3/src/virtus/__init__.py +109 -0
  7. virtus-0.0.3/src/virtus/_version.py +24 -0
  8. virtus-0.0.3/src/virtus/agent.py +177 -0
  9. virtus-0.0.3/src/virtus/capability.py +126 -0
  10. virtus-0.0.3/src/virtus/compaction.py +75 -0
  11. virtus-0.0.3/src/virtus/dispatch.py +144 -0
  12. virtus-0.0.3/src/virtus/journal.py +244 -0
  13. virtus-0.0.3/src/virtus/knowledge.py +401 -0
  14. virtus-0.0.3/src/virtus/lazy.py +166 -0
  15. virtus-0.0.3/src/virtus/live.py +201 -0
  16. virtus-0.0.3/src/virtus/message.py +201 -0
  17. virtus-0.0.3/src/virtus/middleware.py +14 -0
  18. virtus-0.0.3/src/virtus/observe.py +186 -0
  19. virtus-0.0.3/src/virtus/output.py +337 -0
  20. virtus-0.0.3/src/virtus/pdfs.py +51 -0
  21. virtus-0.0.3/src/virtus/provider.py +61 -0
  22. virtus-0.0.3/src/virtus/providers/__init__.py +20 -0
  23. virtus-0.0.3/src/virtus/providers/anthropic.py +200 -0
  24. virtus-0.0.3/src/virtus/providers/chat_completions.py +287 -0
  25. virtus-0.0.3/src/virtus/providers/embeddings.py +55 -0
  26. virtus-0.0.3/src/virtus/providers/factories.py +41 -0
  27. virtus-0.0.3/src/virtus/providers/openai_realtime.py +148 -0
  28. virtus-0.0.3/src/virtus/providers/responses.py +220 -0
  29. virtus-0.0.3/src/virtus/request.py +48 -0
  30. virtus-0.0.3/src/virtus/servers.py +187 -0
  31. virtus-0.0.3/src/virtus/thread.py +236 -0
  32. virtus-0.0.3/src/virtus/tool.py +91 -0
  33. virtus-0.0.3/src/virtus/tools/__init__.py +24 -0
  34. virtus-0.0.3/src/virtus/tools/calculator.py +107 -0
  35. virtus-0.0.3/src/virtus/tools/http.py +86 -0
  36. virtus-0.0.3/src/virtus/tools/net.py +61 -0
  37. virtus-0.0.3/src/virtus/tools/papers.py +102 -0
  38. virtus-0.0.3/src/virtus/tools/pdf.py +41 -0
  39. virtus-0.0.3/src/virtus/tools/websearch.py +122 -0
  40. virtus-0.0.3/src/virtus/tools/wikipedia.py +53 -0
  41. virtus-0.0.3/src/virtus/vault.py +77 -0
  42. virtus-0.0.3/src/virtus/workspace.py +92 -0
@@ -0,0 +1,49 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+ src/virtus/_version.py
9
+
10
+ .tasks/
11
+
12
+ .idea/
13
+
14
+ .python-version
15
+
16
+ # Virtual environments
17
+ .venv
18
+
19
+ .knowledge/
20
+
21
+ # Environment variables and secrets
22
+ .env
23
+ .env.*
24
+ !.env.example
25
+ *.pem
26
+ *.key
27
+ *.crt
28
+ *.p12
29
+ *.pfx
30
+
31
+ # Sensitive files
32
+ *secret*
33
+ *credentials*
34
+ .password
35
+
36
+ # Editor and OS files
37
+ .DS_Store
38
+ *.swp
39
+ *.swo
40
+ *~
41
+ .vscode/settings.json
42
+
43
+ # Logs
44
+ *.log
45
+ logs/
46
+
47
+ # Temporary files
48
+ tmp/
49
+ temp/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Gabriel Francisco
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
virtus-0.0.3/PKG-INFO ADDED
@@ -0,0 +1,547 @@
1
+ Metadata-Version: 2.5
2
+ Name: virtus
3
+ Version: 0.0.3
4
+ Summary: A minimal async framework for LLM agents
5
+ Project-URL: Repository, https://github.com/gabfssilva/virtus
6
+ Author-email: Gabriel Francisco <gabfssilva@gmail.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE.md
9
+ Requires-Python: >=3.12
10
+ Requires-Dist: openai>=1.59.6
11
+ Requires-Dist: pydantic>=2.10.5
12
+ Provides-Extra: anthropic
13
+ Requires-Dist: anthropic>=2.3.0; extra == 'anthropic'
14
+ Provides-Extra: mcp
15
+ Requires-Dist: mcp>=2.3; extra == 'mcp'
16
+ Provides-Extra: pdf
17
+ Requires-Dist: onnxruntime>=1.20; extra == 'pdf'
18
+ Requires-Dist: pypdfium2>=4.30; extra == 'pdf'
19
+ Requires-Dist: rapidocr>=3; extra == 'pdf'
20
+ Provides-Extra: realtime
21
+ Requires-Dist: openai[realtime]; extra == 'realtime'
22
+ Provides-Extra: websearch
23
+ Requires-Dist: ddgs>=9.16; extra == 'websearch'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # virtus
27
+
28
+ A small library for building agents. Virtus allows you to describe agents as regular functions.
29
+
30
+ ```bash
31
+ uv add virtus
32
+ ```
33
+
34
+ Python 3.12+, and the free-threaded builds from 3.14t (3.13t is out: `openai` needs `jiter`, which has no wheel for it; the `pdf` extra installs only on 3.12–3.14 with the GIL, where `onnxruntime` and `opencv` ship wheels). The API is still subject to change.
35
+
36
+ ## Start with a function
37
+
38
+ ```python
39
+ import asyncio
40
+
41
+ from virtus import agent
42
+ from virtus.providers import ChatCompletions
43
+
44
+
45
+ @agent(provider=ChatCompletions())
46
+ async def researcher(question: str) -> str:
47
+ """Research this question: {question}"""
48
+ ...
49
+
50
+
51
+ async def main() -> None:
52
+ print(await researcher("What is retrieval-augmented generation?"))
53
+
54
+
55
+ asyncio.run(main())
56
+ ```
57
+
58
+ With `OPENAI_API_KEY` set, this sends the request to OpenAI and returns the answer as a string.
59
+ The function is a template: its docstring supplies the message, `{question}` is filled from
60
+ the argument, and the return type tells Virtus what to give back. `ChatCompletions` handles
61
+ the model API. You can set a system prompt with `system=` on the decorator.
62
+
63
+ ## Return the findings as data
64
+
65
+ ```python
66
+ from pydantic import BaseModel
67
+
68
+
69
+ class Report(BaseModel):
70
+ summary: str
71
+ findings: list[str]
72
+
73
+
74
+ @agent(provider=ChatCompletions())
75
+ async def review(question: str) -> Report:
76
+ """Explain this research question: {question}"""
77
+ ...
78
+ ```
79
+
80
+ `await review(...)` returns a `Report`, with fields you can use in your application.
81
+ Virtus requests structured JSON from the model and validates it with Pydantic. Other
82
+ structured types, including lists, work the same way.
83
+
84
+ ## Give the researcher a search tool
85
+
86
+ To ground the answer in papers, give the researcher a search tool:
87
+
88
+ ```python
89
+ from virtus.tools import arxiv
90
+
91
+
92
+ @agent(provider=ChatCompletions(), capabilities=[arxiv()])
93
+ async def searching_researcher(question: str) -> str:
94
+ """Research this question: {question}"""
95
+ ...
96
+ ```
97
+
98
+ Now the model can search arXiv before answering. Virtus executes the function and
99
+ sends the result back, then calls the model again. That repeats until the model answers
100
+ without requesting another tool. `arxiv()` returns a tool built with `tool(...)` from a function: its
101
+ signature supplies the tool schema and its docstring describes what it does. Its arguments, such as
102
+ `arxiv(count=10)`, set the defaults and how it reaches the network. You can turn your own sync or async
103
+ functions into tools with the `@tool` decorator. Calls requested together run
104
+ in parallel, and exceptions are returned to the model as tool errors.
105
+
106
+ The `capabilities` list is where you give the agent tools and other behavior. Here it contains
107
+ just the search tool.
108
+
109
+ ## Follow the request through middleware
110
+
111
+ Before giving the researcher more to do, we can watch those steps:
112
+
113
+ ```python
114
+ from collections.abc import AsyncIterator
115
+ from time import perf_counter
116
+
117
+ from virtus import Event, Next, Request
118
+
119
+
120
+ async def timing(request: Request, next: Next) -> AsyncIterator[Event]:
121
+ started = perf_counter()
122
+ async for event in next(request):
123
+ yield event
124
+ print(f"Step {request.iteration}: {perf_counter() - started:.2f}s")
125
+
126
+
127
+ @agent(provider=ChatCompletions(), capabilities=[arxiv(), timing])
128
+ async def traced_researcher(question: str) -> str:
129
+ """Research this question: {question}"""
130
+ ...
131
+ ```
132
+
133
+ If the model calls `arxiv`, `timing` runs around that provider call and the tool's
134
+ execution. It runs again around the next provider call, when the model sees the papers and
135
+ writes its answer. Each of these is one step.
136
+
137
+ This function is middleware. It receives a `Request`, passes it to `next`, and yields the
138
+ events that come back. Code before `next` can inspect or change the request; code around
139
+ the event stream can inspect or change the response. It can also stop a run, retry a step,
140
+ or choose a different provider.
141
+
142
+ The search tool enters the run through middleware too. Its middleware adds the tool to
143
+ the request before passing it on. Written out for this example, that looks like:
144
+
145
+ ```python
146
+ from dataclasses import replace
147
+
148
+
149
+ async def add_search(request: Request, next: Next) -> AsyncIterator[Event]:
150
+ request = replace(request, tools=(*request.tools, arxiv()))
151
+ async for event in next(request):
152
+ yield event
153
+
154
+
155
+ @agent(provider=ChatCompletions(), capabilities=[add_search, timing])
156
+ async def manual_researcher(question: str) -> str:
157
+ """Research this question: {question}"""
158
+ ...
159
+ ```
160
+
161
+ `manual_researcher` makes the same tool available as `traced_researcher`. In both cases the tool is
162
+ added first, so the timing middleware receives a request that already contains it. The
163
+ `capabilities` list forms a chain in the order given, outermost first, and events travel back
164
+ through that chain.
165
+
166
+ All capabilities use this mechanism. Tools add tool definitions, context adds to the system
167
+ prompt, memory supplies documents and tools, and compaction rewrites the history. Even an
168
+ agent passed to another agent becomes middleware that offers delegation. A capability is
169
+ an object whose `middleware()` method supplies one or more middleware functions; plain
170
+ middleware functions can go in the same list.
171
+
172
+ This is also where you customize execution. For example, checking `request.iteration` before
173
+ calling `next` lets you impose a step limit. Replacing `request.provider` lets you switch
174
+ models during a run. The [middleware example](examples/08_middleware.py) shows both.
175
+
176
+ For everyday tracing, [`observe()`](examples/21_observe.py) provides a live tree on stderr
177
+ with provider calls, tool arguments and results, streamed answers, timing and token usage.
178
+ Like our timer, it sees the request at its position in the chain.
179
+
180
+ ## Build on what you have already read
181
+
182
+ The researcher can find papers, but you may already have a collection you want it to consult.
183
+ Add papers saved as PDF, Markdown or text, and somewhere to keep notes between research sessions:
184
+
185
+ ```python
186
+ from virtus import knowledge, memory
187
+
188
+
189
+ @agent(
190
+ provider=ChatCompletions(),
191
+ capabilities=[arxiv(), knowledge("./papers"), memory("./notes")],
192
+ )
193
+ async def informed_researcher(question: str) -> str:
194
+ """Research this question: {question}"""
195
+ ...
196
+ ```
197
+
198
+ The next request passes through the tool middleware, then through `knowledge` and `memory`.
199
+ The model receives the saved papers and any notes that already exist. It also gets tools
200
+ to write or delete notes, so findings from one question can inform the next.
201
+
202
+ Both stores work with PDF, Markdown and text files. A PDF is read as its pages' text, with OCR
203
+ on scanned pages (the `pdf` extra). While their content fits `max_tokens`, it goes in the prompt.
204
+ Beyond that they supply an index and a read tool, then search and read tools when the index
205
+ becomes too large. Long documents are cut into passages of up to `passage=2_000` characters that overlap by `overlap=200`:
206
+ search returns passages (`paper.pdf#7`), and reading a long document gives one passage at a time
207
+ with a pointer to the next. Search uses BM25 by default, matching words in the query against the
208
+ passages. If a question uses different terms from the papers, embeddings let
209
+ you search by meaning:
210
+
211
+ ```python
212
+ from virtus.providers import Embeddings
213
+
214
+
215
+ @agent(
216
+ provider=ChatCompletions(),
217
+ capabilities=[knowledge("./papers", embedder=Embeddings("text-embedding-3-small"))],
218
+ )
219
+ async def librarian(question: str) -> str:
220
+ """Answer from the saved papers: {question}"""
221
+ ...
222
+ ```
223
+
224
+ When the collection needs search, the embedder turns the query and passages into vectors,
225
+ and the store ranks them by cosine similarity. Passage vectors are cached in `.embeddings.json`
226
+ beside the source files, so unchanged passages are reused across searches. The query is
227
+ embedded on each search. `memory` accepts the same `embedder=` option.
228
+
229
+ `Embeddings` uses OpenAI's `/v1/embeddings` API or a compatible `base_url`. `dimensions=` can
230
+ shorten vectors on models that support it; `input_type=True` sends Voyage's query/document
231
+ hint. You can also provide your own `Embedder`. For multiple collections, `name=` separates
232
+ their tools; `Store` exposes loading, reading and async search directly.
233
+ See [memory and knowledge](examples/12_memory_and_knowledge.py).
234
+
235
+ If the researcher should organize files or write a report to disk, add
236
+ [`workspace(path)`](examples/24_workspace.py). It offers read, write, exact edit, list and
237
+ regex search tools confined to that directory.
238
+
239
+ For context held in your application, a `@context` function can return text to add to each
240
+ step's system prompt. When that context and several tools share state, put them in a
241
+ [`Capabilities` class](examples/06_capabilities_class.py): its decorated methods share `self`,
242
+ and tool names get a prefix such as `notebook__save`.
243
+
244
+ ## Follow up on a question
245
+
246
+ ```python
247
+ async def investigate() -> None:
248
+ thread = informed_researcher.thread("rag-review")
249
+ print(await thread("Find papers on retrieval-augmented generation."))
250
+ print(await thread("Which of these evaluate factual accuracy?"))
251
+ ```
252
+
253
+ A direct call starts a fresh conversation. Here, both calls belong to the same thread, so
254
+ the second includes the first exchange. Each turn still goes through the middleware chain,
255
+ and the messages that reach the provider become the thread's history. If middleware redacts
256
+ or summarizes a message, later turns retain that change.
257
+
258
+ For input validation or redaction, `@on_input` wraps a function that rewrites a new
259
+ `UserMessage` or rejects it by raising. `@on_message` processes complete assistant messages
260
+ and tool results. These are middleware adapters too, and both accept sync or async functions.
261
+ See [hooks](examples/07_hooks.py).
262
+
263
+ To return to the investigation after restarting the process, give the researcher a disk-backed journal:
264
+
265
+ ```python
266
+ from virtus import JsonJournal
267
+
268
+
269
+ @agent(
270
+ provider=ChatCompletions(),
271
+ capabilities=[arxiv(), knowledge("./papers"), memory("./notes")],
272
+ journal=JsonJournal("./threads"),
273
+ )
274
+ async def persistent_researcher(question: str) -> str:
275
+ """Research this question: {question}"""
276
+ ...
277
+
278
+
279
+ thread = persistent_researcher.thread("rag-review")
280
+ ```
281
+
282
+ `MemoryJournal` is the default. `JsonJournal` saves each completed step under
283
+ `<directory>/<agent>/<id>.json`; reopening the thread restores its history, and
284
+ `await thread.resume()` continues an interrupted run. Journals persist messages; state held
285
+ by a capability instance needs its own storage. See [persistence](examples/16_persistence.py).
286
+
287
+ Calls on one thread run one at a time. You can inspect `thread.history` and `thread.usage`,
288
+ or delete the conversation and its children with `thread.dispose()`. Each `AssistantMessage`
289
+ also carries the usage reported for that provider call. When research leads to long exchanges,
290
+ [`compaction(max_tokens=32_000, keep=8)`](examples/10_compaction.py) summarizes older messages
291
+ with the same provider while retaining recent ones.
292
+
293
+ ## Stream the answer
294
+
295
+ To display the answer as it arrives, use a streaming return annotation:
296
+
297
+ | Annotation | What you iterate over |
298
+ | --- | --- |
299
+ | `AsyncIterator[str]` | Text chunks. |
300
+ | `AsyncIterator[Report]` | Completed items in a list. |
301
+ | `Snapshot[Report]` | A growing report; unfinished fields may be `None`. |
302
+ | `AsyncIterator[Event]` | Raw deltas, assistant messages and tool results. |
303
+
304
+ `AsyncIterator` comes from `collections.abc`; `Snapshot` and `Event` come from `virtus`.
305
+ These all consume the events coming back through the same middleware chain. See
306
+ [streaming](examples/04_streaming.py) and [structured streaming](examples/11_structured_streaming.py).
307
+
308
+ ## Bring in other tools and agents
309
+
310
+ To search beyond arXiv, add `openalex` for published papers or `websearch` for the wider web.
311
+ `pdf` reads a paper from a URL or a local path, a page range at a time, so the researcher can go past the
312
+ abstract (see [reading PDFs](#read-the-papers-themselves)). `virtus.tools` also includes `calculator`,
313
+ `http` and `wikipedia`. Web search covers pages, news, images, videos and books with date filters; it and
314
+ `pdf` are the ones that need an extra. Each is a function that returns the tool, and its arguments set
315
+ the defaults the model sees and everything else: `pdf(max_chars=50_000, reader=Reader(scale=3))`,
316
+ `websearch(backend="google, bing")`, and `net=Net(timeout=60, retries=3)` on those that go online.
317
+ See [the toolbox](examples/23_toolbox.py).
318
+
319
+ To use tools from an MCP server, open `async with mcp(name=server) as tools:` and add `tools`
320
+ to the agent's capabilities. Servers can be stdio commands, streamable HTTP URLs,
321
+ `Stdio(command, env=...)` or `Http(url, headers=..., auth=...)`. Their tools are named
322
+ `name__tool`; connection credentials stay outside the model's context. The
323
+ [MCP example](examples/26_mcp.py) shows the connection lifecycle.
324
+
325
+ As that collection grows, [`lazy(*capabilities)`](examples/27_lazy_tools.py) offers tool search
326
+ so the model can discover what it needs. Discovered tools stay available in the conversation.
327
+ Anthropic and Responses use native deferred loading; Chat Completions adds discovered tools
328
+ to subsequent requests. If a tool needs a token, add [`secrets(**values)`](examples/25_vault.py)
329
+ after the tools it guards. It substitutes `{{secrets.name}}` before execution and redacts
330
+ the value from the result.
331
+
332
+ A second agent can review the findings before you use them:
333
+
334
+ ```python
335
+ @agent(provider=ChatCompletions())
336
+ async def critic(report: str) -> str:
337
+ """Review this research report for unsupported claims: {report}"""
338
+ ...
339
+
340
+
341
+ @agent(provider=ChatCompletions(), capabilities=[arxiv(), critic])
342
+ async def team(question: str) -> str:
343
+ """Research this question and ask the critic to review your findings: {question}"""
344
+ ...
345
+ ```
346
+
347
+ Adding the critic gives the researcher a `dispatch(to, arguments)` tool. It can address `critic`
348
+ for a one-off review or `critic:topic` to keep a conversation, using the critic's function arguments.
349
+ Multiple addresses run in parallel, with replies or errors keyed by address. Child conversations
350
+ live under the caller's thread in its journal; you can also pass an existing thread as a
351
+ capability. Dispatch accepts text and structured arguments, but cannot pass media parameters.
352
+ See [delegation](examples/09_delegation.py) and [a research team](examples/22_academic_researcher.py).
353
+
354
+ ## Reuse an agent
355
+
356
+ Once an agent is configured, you can build variants without repeating its definition:
357
+
358
+ ```python
359
+ from virtus import observe
360
+ from virtus.providers import Responses
361
+
362
+
363
+ observed_researcher = searching_researcher.extend(observe())
364
+ responses_researcher = searching_researcher.replace(provider=Responses())
365
+ ```
366
+
367
+ `extend` appends capabilities; `replace` changes settings such as `provider`, `system`,
368
+ `capabilities` or `journal`. Both return a new agent, leaving the original unchanged, and keep
369
+ its journal unless you replace it. Here, one variant adds tracing and the other uses Responses
370
+ with the same prompt and tools.
371
+
372
+ ## Change the model or the transport
373
+
374
+ Providers accept model names, credentials and generation settings; the built-in choices are:
375
+
376
+ | From `virtus.providers` | API and credentials |
377
+ | --- | --- |
378
+ | `ChatCompletions(model, ...)` | OpenAI or a compatible `base_url`; `OPENAI_API_KEY`. |
379
+ | `Responses(model, ...)` | OpenAI Responses API; `OPENAI_API_KEY`. |
380
+ | `Anthropic(model, ...)` | Anthropic Messages API; `ANTHROPIC_API_KEY`. |
381
+ | `openrouter(model)` / `omlx(model)` | Chat Completions presets; `OPENROUTER_API_KEY` / optional `OMLX_API_KEY`. |
382
+ | `Embeddings(model, ...)` | OpenAI-compatible `/v1/embeddings`; `base_url`, `dimensions`, optional `input_type=True` for Voyage. |
383
+
384
+ To supply your own integrations, implement the protocols:
385
+ [`Provider.complete(request)`](src/virtus/provider.py) streams deltas followed by one
386
+ `AssistantMessage`, `Embedder.embed(texts, kind)` produces vectors, and
387
+ [`Journal`](src/virtus/journal.py) stores history. `LiveProvider` and `Session` define realtime
388
+ connections. These supply the model and storage used by a run; capabilities supply the
389
+ middleware around its steps.
390
+
391
+ ## Ask about a figure
392
+
393
+ ```python
394
+ from virtus import Image
395
+
396
+
397
+ @agent(provider=ChatCompletions())
398
+ async def explain_figure(figure: Image, question: str) -> str:
399
+ """Answer {question} about this figure: {figure}"""
400
+ ...
401
+
402
+
403
+ async def inspect_figure() -> None:
404
+ print(await explain_figure(Image.read("figure.png"), "What do the axes represent?"))
405
+ ```
406
+
407
+ The image is attached to the user message; `{figure}` appears as `[image]` in the prompt.
408
+ The text question and the image reach the model together. All three chat providers accept
409
+ image input, and tools can return `Image` values too. See [images](examples/14_images.py).
410
+
411
+ The return annotation works in the other direction as well. With a model that generates
412
+ images, the researcher can turn an explanation into a diagram:
413
+
414
+ ```python
415
+ from virtus.providers import openrouter
416
+
417
+
418
+ @agent(provider=openrouter("google/gemini-3.1-flash-lite-image"))
419
+ async def illustrate(description: str) -> Image:
420
+ """Draw a diagram of this process: {description}"""
421
+ ...
422
+ ```
423
+
424
+ `await illustrate(...)` returns an `Image` with `data` and `media_type` fields. This example
425
+ uses OpenRouter and needs `OPENROUTER_API_KEY`. Image generation goes through `ChatCompletions`
426
+ with a model that supports it; see [image generation](examples/18_image_generation.py).
427
+
428
+ ## Read the papers themselves
429
+
430
+ A `File` holds a PDF. Like an image, it can be an argument or a tool's return value:
431
+
432
+ ```python
433
+ from virtus import File
434
+
435
+
436
+ @agent(provider=Responses())
437
+ async def summarize_paper(paper: File) -> str:
438
+ """Summarize the method in {paper}."""
439
+ ...
440
+
441
+
442
+ async def read_paper() -> None:
443
+ print(await summarize_paper(File.read("paper.pdf")))
444
+ ```
445
+
446
+ Anthropic, Responses and Chat Completions send the file itself, and the model reads its layout and
447
+ figures as well as its text. A `File` can also carry `text`, which goes in its place to a server whose
448
+ models read no files (`omlx()`, or `ChatCompletions(..., files=False)`) and to the Realtime API. A file
449
+ with no text is refused there.
450
+
451
+ The `pdf` tool returns the requested pages as a `File` along with their text. The text comes from the
452
+ PDF's text layer, or from OCR (RapidOCR) on scanned pages, so the fallback works on scans too.
453
+
454
+ ## Listen to a talk, or hear the summary
455
+
456
+ ```python
457
+ from virtus import Audio
458
+
459
+
460
+ @agent(provider=ChatCompletions("gpt-audio-mini"))
461
+ async def summarize_talk(recording: Audio) -> str:
462
+ """Summarize the main findings and limitations discussed in {recording}."""
463
+ ...
464
+
465
+
466
+ @agent(provider=ChatCompletions("gpt-audio-mini", voice="alloy"))
467
+ async def read_aloud(summary: str) -> Audio:
468
+ """Read this summary aloud: {summary}"""
469
+ ...
470
+
471
+
472
+ async def spoken_summary() -> Audio:
473
+ summary = await summarize_talk(Audio.read("talk.wav"))
474
+ return await read_aloud(summary)
475
+ ```
476
+
477
+ The first call attaches the recording to the message and returns text. The second asks for
478
+ a spoken answer, returned as an `Audio` value containing WAV bytes in `data`. Audio input
479
+ and output require an audio-capable model through `ChatCompletions`; tools can return audio
480
+ too. See [audio](examples/17_audio.py) for saving and reusing a generated clip.
481
+
482
+ ## Discuss the findings in realtime
483
+
484
+ For a conversation where you can speak, hear the answer and interrupt it, open a live thread:
485
+
486
+ ```python
487
+ from collections.abc import AsyncIterator, Callable
488
+
489
+ from virtus import AudioDelta, Interrupted, TextDelta
490
+
491
+
492
+ @agent(provider=ChatCompletions("gpt-realtime-2.1-mini"), capabilities=[arxiv()])
493
+ async def voice_researcher(question: str) -> str:
494
+ """Discuss this research question: {question}"""
495
+ ...
496
+
497
+
498
+ async def discuss(
499
+ frames: AsyncIterator[bytes],
500
+ play: Callable[[bytes], None],
501
+ stop: Callable[[], None],
502
+ ) -> None:
503
+ async with voice_researcher.thread("discussion").live() as call:
504
+ async for event in call.converse(frames):
505
+ match event:
506
+ case AudioDelta(data=pcm):
507
+ play(pcm)
508
+ case Interrupted():
509
+ stop()
510
+ case TextDelta(content=text):
511
+ print(text, end="", flush=True)
512
+ ```
513
+
514
+ Your application supplies microphone frames and the functions that play or stop the speaker.
515
+ Audio in both directions is 24 kHz, 16-bit mono PCM. `converse` detects speech and pauses,
516
+ and emits `Interrupted` when the user talks over a reply so you can stop playback.
517
+
518
+ Each turn runs through the same middleware and tools as a typed conversation, and is saved
519
+ in the thread's history. This needs the `realtime` extra and a realtime model on OpenAI or
520
+ oMLX. Pass `speech=` for your own speech detector, or control turns directly with `hear`,
521
+ `turn` and `interrupt`; `show` adds an image. The [realtime voice example](examples/19_realtime_voice.py)
522
+ includes microphone and speaker setup.
523
+
524
+ ## Development
525
+
526
+ Install the extras you use:
527
+
528
+ ```bash
529
+ uv add "virtus[anthropic]"
530
+ uv add "virtus[mcp]"
531
+ uv add "virtus[websearch]"
532
+ uv add "virtus[pdf]"
533
+ uv add "virtus[realtime]"
534
+ ```
535
+
536
+ Runnable scripts are in [examples/](examples/). To run the offline checks:
537
+
538
+ ```bash
539
+ uv sync --all-extras
540
+ uv run ruff check .
541
+ uv run pyrefly check
542
+ uv run pytest -q
543
+ ```
544
+
545
+ ## License
546
+
547
+ [MIT](LICENSE.md).