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.
- virtus-0.0.3/.gitignore +49 -0
- virtus-0.0.3/LICENSE.md +21 -0
- virtus-0.0.3/PKG-INFO +547 -0
- virtus-0.0.3/README.md +522 -0
- virtus-0.0.3/pyproject.toml +80 -0
- virtus-0.0.3/src/virtus/__init__.py +109 -0
- virtus-0.0.3/src/virtus/_version.py +24 -0
- virtus-0.0.3/src/virtus/agent.py +177 -0
- virtus-0.0.3/src/virtus/capability.py +126 -0
- virtus-0.0.3/src/virtus/compaction.py +75 -0
- virtus-0.0.3/src/virtus/dispatch.py +144 -0
- virtus-0.0.3/src/virtus/journal.py +244 -0
- virtus-0.0.3/src/virtus/knowledge.py +401 -0
- virtus-0.0.3/src/virtus/lazy.py +166 -0
- virtus-0.0.3/src/virtus/live.py +201 -0
- virtus-0.0.3/src/virtus/message.py +201 -0
- virtus-0.0.3/src/virtus/middleware.py +14 -0
- virtus-0.0.3/src/virtus/observe.py +186 -0
- virtus-0.0.3/src/virtus/output.py +337 -0
- virtus-0.0.3/src/virtus/pdfs.py +51 -0
- virtus-0.0.3/src/virtus/provider.py +61 -0
- virtus-0.0.3/src/virtus/providers/__init__.py +20 -0
- virtus-0.0.3/src/virtus/providers/anthropic.py +200 -0
- virtus-0.0.3/src/virtus/providers/chat_completions.py +287 -0
- virtus-0.0.3/src/virtus/providers/embeddings.py +55 -0
- virtus-0.0.3/src/virtus/providers/factories.py +41 -0
- virtus-0.0.3/src/virtus/providers/openai_realtime.py +148 -0
- virtus-0.0.3/src/virtus/providers/responses.py +220 -0
- virtus-0.0.3/src/virtus/request.py +48 -0
- virtus-0.0.3/src/virtus/servers.py +187 -0
- virtus-0.0.3/src/virtus/thread.py +236 -0
- virtus-0.0.3/src/virtus/tool.py +91 -0
- virtus-0.0.3/src/virtus/tools/__init__.py +24 -0
- virtus-0.0.3/src/virtus/tools/calculator.py +107 -0
- virtus-0.0.3/src/virtus/tools/http.py +86 -0
- virtus-0.0.3/src/virtus/tools/net.py +61 -0
- virtus-0.0.3/src/virtus/tools/papers.py +102 -0
- virtus-0.0.3/src/virtus/tools/pdf.py +41 -0
- virtus-0.0.3/src/virtus/tools/websearch.py +122 -0
- virtus-0.0.3/src/virtus/tools/wikipedia.py +53 -0
- virtus-0.0.3/src/virtus/vault.py +77 -0
- virtus-0.0.3/src/virtus/workspace.py +92 -0
virtus-0.0.3/.gitignore
ADDED
|
@@ -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/
|
virtus-0.0.3/LICENSE.md
ADDED
|
@@ -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).
|