virtus 0.0.3__py3-none-any.whl
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/__init__.py +109 -0
- virtus/_version.py +24 -0
- virtus/agent.py +177 -0
- virtus/capability.py +126 -0
- virtus/compaction.py +75 -0
- virtus/dispatch.py +144 -0
- virtus/journal.py +244 -0
- virtus/knowledge.py +401 -0
- virtus/lazy.py +166 -0
- virtus/live.py +201 -0
- virtus/message.py +201 -0
- virtus/middleware.py +14 -0
- virtus/observe.py +186 -0
- virtus/output.py +337 -0
- virtus/pdfs.py +51 -0
- virtus/provider.py +61 -0
- virtus/providers/__init__.py +20 -0
- virtus/providers/anthropic.py +200 -0
- virtus/providers/chat_completions.py +287 -0
- virtus/providers/embeddings.py +55 -0
- virtus/providers/factories.py +41 -0
- virtus/providers/openai_realtime.py +148 -0
- virtus/providers/responses.py +220 -0
- virtus/request.py +48 -0
- virtus/servers.py +187 -0
- virtus/thread.py +236 -0
- virtus/tool.py +91 -0
- virtus/tools/__init__.py +24 -0
- virtus/tools/calculator.py +107 -0
- virtus/tools/http.py +86 -0
- virtus/tools/net.py +61 -0
- virtus/tools/papers.py +102 -0
- virtus/tools/pdf.py +41 -0
- virtus/tools/websearch.py +122 -0
- virtus/tools/wikipedia.py +53 -0
- virtus/vault.py +77 -0
- virtus/workspace.py +92 -0
- virtus-0.0.3.dist-info/METADATA +547 -0
- virtus-0.0.3.dist-info/RECORD +41 -0
- virtus-0.0.3.dist-info/WHEEL +4 -0
- virtus-0.0.3.dist-info/licenses/LICENSE.md +21 -0
virtus/__init__.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING
|
|
2
|
+
|
|
3
|
+
from virtus.agent import Agent, agent
|
|
4
|
+
from virtus.capability import Capabilities, context, on_input, on_message
|
|
5
|
+
from virtus.compaction import compaction
|
|
6
|
+
from virtus.journal import Journal, JsonJournal, MemoryJournal
|
|
7
|
+
from virtus.knowledge import Document, Passage, Store, knowledge, memory
|
|
8
|
+
from virtus.lazy import lazy
|
|
9
|
+
from virtus.live import Interrupted, Level, Live
|
|
10
|
+
from virtus.message import (
|
|
11
|
+
AssistantMessage,
|
|
12
|
+
Audio,
|
|
13
|
+
AudioDelta,
|
|
14
|
+
AudioType,
|
|
15
|
+
Delta,
|
|
16
|
+
Event,
|
|
17
|
+
File,
|
|
18
|
+
FileType,
|
|
19
|
+
Image,
|
|
20
|
+
ImageType,
|
|
21
|
+
MediaType,
|
|
22
|
+
Message,
|
|
23
|
+
TextDelta,
|
|
24
|
+
ToolCall,
|
|
25
|
+
ToolCallDelta,
|
|
26
|
+
ToolResult,
|
|
27
|
+
Usage,
|
|
28
|
+
UserMessage,
|
|
29
|
+
)
|
|
30
|
+
from virtus.middleware import Capability, Middleware, Next
|
|
31
|
+
from virtus.observe import observe
|
|
32
|
+
from virtus.output import Snapshot
|
|
33
|
+
from virtus.provider import Embedder, LiveProvider, Provider, Session
|
|
34
|
+
from virtus.request import Request
|
|
35
|
+
from virtus.thread import Conversation, Thread
|
|
36
|
+
from virtus.tool import Tool, tool
|
|
37
|
+
from virtus.vault import secrets
|
|
38
|
+
from virtus.workspace import workspace
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from virtus.servers import Http, Stdio, mcp
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"Agent",
|
|
45
|
+
"AssistantMessage",
|
|
46
|
+
"Audio",
|
|
47
|
+
"AudioDelta",
|
|
48
|
+
"AudioType",
|
|
49
|
+
"Capabilities",
|
|
50
|
+
"Capability",
|
|
51
|
+
"Conversation",
|
|
52
|
+
"Delta",
|
|
53
|
+
"Document",
|
|
54
|
+
"Embedder",
|
|
55
|
+
"Event",
|
|
56
|
+
"File",
|
|
57
|
+
"FileType",
|
|
58
|
+
"Http",
|
|
59
|
+
"Image",
|
|
60
|
+
"ImageType",
|
|
61
|
+
"Interrupted",
|
|
62
|
+
"Journal",
|
|
63
|
+
"JsonJournal",
|
|
64
|
+
"Level",
|
|
65
|
+
"Live",
|
|
66
|
+
"LiveProvider",
|
|
67
|
+
"MediaType",
|
|
68
|
+
"MemoryJournal",
|
|
69
|
+
"Message",
|
|
70
|
+
"Middleware",
|
|
71
|
+
"Next",
|
|
72
|
+
"Passage",
|
|
73
|
+
"Provider",
|
|
74
|
+
"Request",
|
|
75
|
+
"Session",
|
|
76
|
+
"Snapshot",
|
|
77
|
+
"Stdio",
|
|
78
|
+
"Store",
|
|
79
|
+
"TextDelta",
|
|
80
|
+
"Thread",
|
|
81
|
+
"Tool",
|
|
82
|
+
"ToolCall",
|
|
83
|
+
"ToolCallDelta",
|
|
84
|
+
"ToolResult",
|
|
85
|
+
"Usage",
|
|
86
|
+
"UserMessage",
|
|
87
|
+
"agent",
|
|
88
|
+
"compaction",
|
|
89
|
+
"context",
|
|
90
|
+
"knowledge",
|
|
91
|
+
"lazy",
|
|
92
|
+
"mcp",
|
|
93
|
+
"memory",
|
|
94
|
+
"observe",
|
|
95
|
+
"on_input",
|
|
96
|
+
"on_message",
|
|
97
|
+
"secrets",
|
|
98
|
+
"tool",
|
|
99
|
+
"workspace",
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def __getattr__(name: str) -> object:
|
|
104
|
+
"""`mcp`, `Stdio` and `Http` load on demand, so the `mcp` extra is only needed by those who use it."""
|
|
105
|
+
if name in ("Http", "Stdio", "mcp"):
|
|
106
|
+
from virtus import servers
|
|
107
|
+
|
|
108
|
+
return getattr(servers, name)
|
|
109
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
virtus/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.0.3'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 0, 3)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
virtus/agent.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Final, overload
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
from virtus.capability import flatten
|
|
10
|
+
from virtus.dispatch import Target, dispatching
|
|
11
|
+
from virtus.journal import Journal, MemoryJournal
|
|
12
|
+
from virtus.message import Audio, File, Image, UserMessage, placeholder
|
|
13
|
+
from virtus.middleware import Capability, Middleware
|
|
14
|
+
from virtus.output import Output, output
|
|
15
|
+
from virtus.provider import Provider
|
|
16
|
+
from virtus.thread import Conversation, Thread
|
|
17
|
+
from virtus.tool import parameters_model
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Unset:
|
|
21
|
+
"""Marks a `replace` setting to keep."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
UNSET: Final = Unset()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Agent[**P, R](Capability):
|
|
28
|
+
"""An LLM call loop described by a template function.
|
|
29
|
+
|
|
30
|
+
The template's docstring, formatted with the call arguments, is the user message. Its return
|
|
31
|
+
annotation decides what a call returns; see `virtus.output`. `R` is that return type, so an
|
|
32
|
+
agent declared `-> Person` has `R = Awaitable[Person]` and one declared `-> AsyncIterator[Person]`
|
|
33
|
+
has `R = AsyncIterator[Person]`.
|
|
34
|
+
|
|
35
|
+
Used as a capability, the agent becomes a target of the caller's `dispatch` tool (`virtus.dispatch`):
|
|
36
|
+
the calling model addresses it by name for a one-off request, or as `name:thread` to keep a
|
|
37
|
+
conversation going, and passes the template's arguments, which format the message exactly as a
|
|
38
|
+
direct call does. Those threads, one-off requests included, live under the caller's thread in its
|
|
39
|
+
journal, so separate conversations never share them.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
template: Callable[P, object],
|
|
45
|
+
*,
|
|
46
|
+
output: Output[R],
|
|
47
|
+
provider: Provider,
|
|
48
|
+
name: str | None = None,
|
|
49
|
+
description: str | None = None,
|
|
50
|
+
system: str = "",
|
|
51
|
+
capabilities: Sequence[Capability | Middleware] = (),
|
|
52
|
+
journal: Journal | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
signature = inspect.signature(template)
|
|
55
|
+
|
|
56
|
+
self.name = name or template.__name__
|
|
57
|
+
self.description = description or inspect.getdoc(template) or ""
|
|
58
|
+
self.provider = provider
|
|
59
|
+
self.system = system
|
|
60
|
+
self.journal: Journal = journal or MemoryJournal()
|
|
61
|
+
"""Where every thread of this agent keeps its history; direct one-off calls are never written to it."""
|
|
62
|
+
self.output: Final[Output[R]] = output
|
|
63
|
+
self.capabilities: Final[tuple[Capability | Middleware, ...]] = tuple(capabilities)
|
|
64
|
+
self.chain: tuple[Middleware, ...] = flatten(capabilities)
|
|
65
|
+
"""Middleware applied around each step of a run, outermost first."""
|
|
66
|
+
self.parameters: type[BaseModel] = parameters_model(template)
|
|
67
|
+
self._template = template
|
|
68
|
+
self._signature = signature
|
|
69
|
+
self._prompt = inspect.getdoc(template) or "\n\n".join(f"{{{p}}}" for p in signature.parameters)
|
|
70
|
+
|
|
71
|
+
if not self._prompt:
|
|
72
|
+
raise TypeError(f"{self.name}: an agent needs parameters or a docstring")
|
|
73
|
+
|
|
74
|
+
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R:
|
|
75
|
+
return Thread(self)(*args, **kwargs)
|
|
76
|
+
|
|
77
|
+
def replace(
|
|
78
|
+
self,
|
|
79
|
+
*,
|
|
80
|
+
provider: Provider | Unset = UNSET,
|
|
81
|
+
name: str | Unset = UNSET,
|
|
82
|
+
description: str | Unset = UNSET,
|
|
83
|
+
system: str | Unset = UNSET,
|
|
84
|
+
capabilities: Sequence[Capability | Middleware] | Unset = UNSET,
|
|
85
|
+
journal: Journal | Unset = UNSET,
|
|
86
|
+
) -> "Agent[P, R]":
|
|
87
|
+
"""A copy with these settings changed; same template and output mode, and the same journal unless
|
|
88
|
+
one is given, so both see the same threads.
|
|
89
|
+
|
|
90
|
+
`capabilities` replaces the whole list; `extend` appends to it.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def pick[T](value: T | Unset, current: T) -> T:
|
|
94
|
+
return current if isinstance(value, Unset) else value
|
|
95
|
+
|
|
96
|
+
return Agent(
|
|
97
|
+
self._template,
|
|
98
|
+
output=self.output,
|
|
99
|
+
provider=pick(provider, self.provider),
|
|
100
|
+
name=pick(name, self.name),
|
|
101
|
+
description=pick(description, self.description),
|
|
102
|
+
system=pick(system, self.system),
|
|
103
|
+
capabilities=pick(capabilities, self.capabilities),
|
|
104
|
+
journal=pick(journal, self.journal),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def extend(self, *capabilities: Capability | Middleware) -> "Agent[P, R]":
|
|
108
|
+
"""A copy with these capabilities appended after the current ones."""
|
|
109
|
+
return self.replace(capabilities=(*self.capabilities, *capabilities))
|
|
110
|
+
|
|
111
|
+
def thread(self, id: str | None = None) -> Thread[P, R]:
|
|
112
|
+
"""A handle to the conversation with this id in the journal, new or already under way.
|
|
113
|
+
|
|
114
|
+
Without an id, start a new one.
|
|
115
|
+
"""
|
|
116
|
+
return Thread(self, (self.name, id or uuid4().hex), self.journal)
|
|
117
|
+
|
|
118
|
+
def input(self, *args: object, **kwargs: object) -> UserMessage:
|
|
119
|
+
"""The user message a call with these arguments sends.
|
|
120
|
+
|
|
121
|
+
`Image`, `Audio` and `File` arguments, alone or in a sequence, are attached to the message; in
|
|
122
|
+
the prompt they read as `[image]`, `[audio]` and `[file]`.
|
|
123
|
+
"""
|
|
124
|
+
bound = self._signature.bind(*args, **kwargs)
|
|
125
|
+
bound.apply_defaults()
|
|
126
|
+
|
|
127
|
+
values = list(bound.arguments.values())
|
|
128
|
+
images = tuple(image for value in values for image in Image.found_in(value))
|
|
129
|
+
audio = tuple(clip for value in values for clip in Audio.found_in(value))
|
|
130
|
+
files = tuple(file for value in values for file in File.found_in(value))
|
|
131
|
+
text = {name: placeholder(value) or value for name, value in bound.arguments.items()}
|
|
132
|
+
return UserMessage(self._prompt.format(**text), images, audio, files)
|
|
133
|
+
|
|
134
|
+
def middleware(self) -> tuple[Middleware, ...]:
|
|
135
|
+
def conversation(parent: Conversation, thread: str | None) -> Conversation:
|
|
136
|
+
return parent.child((self.name, thread or uuid4().hex), self)
|
|
137
|
+
|
|
138
|
+
return (dispatching(Target(self, conversation)),)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def agent(
|
|
142
|
+
*,
|
|
143
|
+
provider: Provider,
|
|
144
|
+
name: str | None = None,
|
|
145
|
+
description: str | None = None,
|
|
146
|
+
system: str = "",
|
|
147
|
+
capabilities: Sequence[Capability | Middleware] = (),
|
|
148
|
+
journal: Journal | None = None,
|
|
149
|
+
) -> "Decorator":
|
|
150
|
+
"""Turn an async template function into an `Agent`; see `virtus.output` for the return types."""
|
|
151
|
+
return Decorator(provider, name, description, system, capabilities, journal)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass(frozen=True)
|
|
155
|
+
class Decorator:
|
|
156
|
+
provider: Provider
|
|
157
|
+
name: str | None
|
|
158
|
+
description: str | None
|
|
159
|
+
system: str
|
|
160
|
+
capabilities: Sequence[Capability | Middleware]
|
|
161
|
+
journal: Journal | None
|
|
162
|
+
|
|
163
|
+
@overload
|
|
164
|
+
def __call__[**P, T](self, template: Callable[P, Awaitable[AsyncIterator[T]]]) -> Agent[P, AsyncIterator[T]]: ...
|
|
165
|
+
@overload
|
|
166
|
+
def __call__[**P, Out](self, template: Callable[P, Awaitable[Out]]) -> Agent[P, Awaitable[Out]]: ...
|
|
167
|
+
def __call__[**P](self, template: Callable[P, Awaitable[object]]) -> Agent[P, object]:
|
|
168
|
+
return Agent(
|
|
169
|
+
template,
|
|
170
|
+
output=output(template),
|
|
171
|
+
provider=self.provider,
|
|
172
|
+
name=self.name,
|
|
173
|
+
description=self.description,
|
|
174
|
+
system=self.system,
|
|
175
|
+
capabilities=self.capabilities,
|
|
176
|
+
journal=self.journal,
|
|
177
|
+
)
|
virtus/capability.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Sequence
|
|
3
|
+
from dataclasses import dataclass, replace
|
|
4
|
+
from itertools import takewhile
|
|
5
|
+
from types import MethodType
|
|
6
|
+
|
|
7
|
+
from virtus.message import AudioDelta, Event, Message, TextDelta, ToolCallDelta, UserMessage
|
|
8
|
+
from virtus.middleware import Capability, Middleware, Next
|
|
9
|
+
from virtus.request import Request
|
|
10
|
+
from virtus.tool import Tool
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Capabilities(Capability):
|
|
14
|
+
"""Groups tools, context and hooks declared as methods, so they can share state through `self`.
|
|
15
|
+
|
|
16
|
+
Tool names are prefixed with the class name in snake case: `Memory.remember` -> `memory__remember`.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def middleware(self) -> tuple[Middleware, ...]:
|
|
20
|
+
prefix = re.sub(r"(?<!^)(?=[A-Z])", "_", type(self).__name__).lower()
|
|
21
|
+
names = dict.fromkeys(
|
|
22
|
+
name
|
|
23
|
+
for klass in reversed(type(self).__mro__)
|
|
24
|
+
for name, value in vars(klass).items()
|
|
25
|
+
if isinstance(value, Capability)
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def member_middleware(name: str) -> Iterator[Middleware]:
|
|
29
|
+
match getattr(self, name):
|
|
30
|
+
case Tool() as tool:
|
|
31
|
+
yield from replace(tool, name=f"{prefix}__{tool.name}").middleware()
|
|
32
|
+
case Capability() as capability:
|
|
33
|
+
yield from capability.middleware()
|
|
34
|
+
|
|
35
|
+
return tuple(f for name in names for f in member_middleware(name))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Context(Capability):
|
|
40
|
+
function: Callable[..., str | Awaitable[str]]
|
|
41
|
+
|
|
42
|
+
def __get__(self, instance: object | None, owner: type | None = None) -> "Context":
|
|
43
|
+
return self if instance is None else replace(self, function=MethodType(self.function, instance))
|
|
44
|
+
|
|
45
|
+
def middleware(self) -> tuple[Middleware, ...]:
|
|
46
|
+
async def add(request: Request, next: Next) -> AsyncIterator[Event]:
|
|
47
|
+
text = await _resolve(self.function())
|
|
48
|
+
system = "\n\n".join(part for part in (request.system, text) if part)
|
|
49
|
+
async for event in next(replace(request, system=system)):
|
|
50
|
+
yield event
|
|
51
|
+
|
|
52
|
+
return (add,)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class OnInput(Capability):
|
|
57
|
+
function: Callable[..., UserMessage | Awaitable[UserMessage]]
|
|
58
|
+
|
|
59
|
+
def __get__(self, instance: object | None, owner: type | None = None) -> "OnInput":
|
|
60
|
+
return self if instance is None else replace(self, function=MethodType(self.function, instance))
|
|
61
|
+
|
|
62
|
+
def middleware(self) -> tuple[Middleware, ...]:
|
|
63
|
+
async def rewrite(request: Request, next: Next) -> AsyncIterator[Event]:
|
|
64
|
+
if request.iteration == 0:
|
|
65
|
+
request = replace(request, messages=await self._rewrite(request.messages))
|
|
66
|
+
|
|
67
|
+
async for event in next(request):
|
|
68
|
+
yield event
|
|
69
|
+
|
|
70
|
+
return (rewrite,)
|
|
71
|
+
|
|
72
|
+
async def _rewrite(self, messages: tuple[Message, ...]) -> tuple[Message, ...]:
|
|
73
|
+
"""The new input is the run of user messages at the end of the conversation."""
|
|
74
|
+
start = len(messages) - sum(1 for _ in takewhile(lambda m: isinstance(m, UserMessage), reversed(messages)))
|
|
75
|
+
rewritten = [await _resolve(self.function(m)) if isinstance(m, UserMessage) else m for m in messages[start:]]
|
|
76
|
+
return (*messages[:start], *rewritten)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class OnMessage(Capability):
|
|
81
|
+
"""Sees every complete message: assistant replies, including tool calls, and tool results.
|
|
82
|
+
|
|
83
|
+
Streamed deltas pass through untouched. Raising on an assistant message stops the run
|
|
84
|
+
before its tool calls execute; returning a modified message does not change which calls run.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
function: Callable[..., Message | Awaitable[Message]]
|
|
88
|
+
|
|
89
|
+
def __get__(self, instance: object | None, owner: type | None = None) -> "OnMessage":
|
|
90
|
+
return self if instance is None else replace(self, function=MethodType(self.function, instance))
|
|
91
|
+
|
|
92
|
+
def middleware(self) -> tuple[Middleware, ...]:
|
|
93
|
+
async def apply(request: Request, next: Next) -> AsyncIterator[Event]:
|
|
94
|
+
async for event in next(request):
|
|
95
|
+
match event:
|
|
96
|
+
case TextDelta() | AudioDelta() | ToolCallDelta():
|
|
97
|
+
yield event
|
|
98
|
+
case message:
|
|
99
|
+
result = await _resolve(self.function(message))
|
|
100
|
+
if type(result) is not type(message):
|
|
101
|
+
raise TypeError(f"on_message must return a {type(message).__name__}, got {result!r}")
|
|
102
|
+
yield result
|
|
103
|
+
|
|
104
|
+
return (apply,)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def context(function: Callable[..., str | Awaitable[str]]) -> Context:
|
|
108
|
+
"""Append the returned text to the system prompt of every provider call."""
|
|
109
|
+
return Context(function)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def on_input(function: Callable[..., UserMessage | Awaitable[UserMessage]]) -> OnInput:
|
|
113
|
+
"""Rewrite, or reject by raising, each new user message once per run."""
|
|
114
|
+
return OnInput(function)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def on_message(function: Callable[..., Message | Awaitable[Message]]) -> OnMessage:
|
|
118
|
+
return OnMessage(function)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def flatten(capabilities: Sequence[Capability | Middleware]) -> tuple[Middleware, ...]:
|
|
122
|
+
return tuple(f for c in capabilities for f in (c.middleware() if isinstance(c, Capability) else (c,)))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def _resolve[T](value: T | Awaitable[T]) -> T:
|
|
126
|
+
return await value if isinstance(value, Awaitable) else value
|
virtus/compaction.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from collections.abc import AsyncIterator, Sequence
|
|
2
|
+
from dataclasses import replace
|
|
3
|
+
|
|
4
|
+
from virtus.message import AssistantMessage, Event, Message, ToolResult, UserMessage
|
|
5
|
+
from virtus.middleware import Middleware, Next
|
|
6
|
+
from virtus.provider import Provider
|
|
7
|
+
from virtus.request import Request
|
|
8
|
+
|
|
9
|
+
SUMMARY_PROMPT = (
|
|
10
|
+
"Summarize the conversation below so the summary can replace it. Keep facts, decisions, results "
|
|
11
|
+
"of tool calls, open questions and anything the user asked to remember. Be concise."
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
SUMMARY = "Summary of the earlier conversation:"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def compaction(
|
|
19
|
+
max_tokens: int = 32_000,
|
|
20
|
+
keep: int = 8,
|
|
21
|
+
*,
|
|
22
|
+
chars_per_token: int = 4,
|
|
23
|
+
provider: Provider | None = None,
|
|
24
|
+
prompt: str = SUMMARY_PROMPT,
|
|
25
|
+
header: str = SUMMARY,
|
|
26
|
+
) -> Middleware:
|
|
27
|
+
"""Replace older messages with a summary once the conversation exceeds `max_tokens`.
|
|
28
|
+
|
|
29
|
+
Tokens are estimated as `chars_per_token` characters each. At least the last `keep` messages stay
|
|
30
|
+
verbatim; the boundary moves back so a tool result is never separated from the call that produced it.
|
|
31
|
+
The summary is written by `provider`, by default the request's, following `prompt`, and goes in as a
|
|
32
|
+
user message opened by `header`. Like any rewrite of the messages it becomes the history, so it is not
|
|
33
|
+
recomputed until the conversation grows past the limit again.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
async def summarize(messages: Sequence[Message], request: Request) -> str:
|
|
37
|
+
summary = replace(request, system=prompt, messages=(UserMessage(_transcript(messages)),), tools=(), output=None)
|
|
38
|
+
replies = [e async for e in (provider or request.provider).complete(summary) if isinstance(e, AssistantMessage)]
|
|
39
|
+
return replies[-1].content
|
|
40
|
+
|
|
41
|
+
async def compact(request: Request, next: Next) -> AsyncIterator[Event]:
|
|
42
|
+
messages = request.messages
|
|
43
|
+
start = max(
|
|
44
|
+
(i for i in range(1, len(messages) - keep + 1) if not isinstance(messages[i], ToolResult)),
|
|
45
|
+
default=0,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
if start and len(_transcript(messages)) // chars_per_token > max_tokens:
|
|
49
|
+
summary = UserMessage(f"{header}\n\n{await summarize(messages[:start], request)}")
|
|
50
|
+
request = replace(request, messages=(summary, *messages[start:]))
|
|
51
|
+
|
|
52
|
+
async for event in next(request):
|
|
53
|
+
yield event
|
|
54
|
+
|
|
55
|
+
return compact
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _transcript(messages: Sequence[Message]) -> str:
|
|
59
|
+
def lines(message: Message) -> list[str]:
|
|
60
|
+
match message:
|
|
61
|
+
case UserMessage(content=content, images=images, audio=audio, files=files):
|
|
62
|
+
return [f"user: {content}{_media(images, audio, files)}"]
|
|
63
|
+
case AssistantMessage(content=content, tool_calls=calls, images=images, audio=audio):
|
|
64
|
+
text = [f"assistant: {content}{_media(images, (audio,) if audio else ())}"] if content or images else []
|
|
65
|
+
return [*text, *(f"assistant called {c.name}({c.arguments})" for c in calls)]
|
|
66
|
+
case ToolResult(name=name, content=content, images=images, audio=audio, files=files):
|
|
67
|
+
return [f"{name} returned: {content}{_media(images, audio, files)}"]
|
|
68
|
+
|
|
69
|
+
return "\n".join(line for message in messages for line in lines(message))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _media(images: Sequence[object], audio: Sequence[object], files: Sequence[object] = ()) -> str:
|
|
73
|
+
counts = ((images, "image(s)"), (audio, "audio clip(s)"), (files, "file(s)"))
|
|
74
|
+
notes = [f"{len(items)} {kind}" for items, kind in counts if items]
|
|
75
|
+
return f" [{', '.join(notes)}]" if notes else ""
|
virtus/dispatch.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""The one tool through which an agent talks to other agents.
|
|
2
|
+
|
|
3
|
+
Every agent (or thread) given as a capability adds itself as a target of the same `dispatch` tool
|
|
4
|
+
instead of becoming a tool of its own, so the model learns one tool however many agents it can
|
|
5
|
+
reach. The `arguments` are those of the addressed agent's template, which formats the message as a
|
|
6
|
+
direct call would; the targets and their open conversations are listed in the system prompt.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import re
|
|
13
|
+
from collections.abc import AsyncIterator, Callable, Mapping
|
|
14
|
+
from dataclasses import dataclass, replace
|
|
15
|
+
from functools import reduce
|
|
16
|
+
from operator import or_
|
|
17
|
+
from typing import TYPE_CHECKING, get_args
|
|
18
|
+
|
|
19
|
+
from pydantic import Field, TypeAdapter, create_model
|
|
20
|
+
|
|
21
|
+
from virtus.message import Audio, Event, Image
|
|
22
|
+
from virtus.middleware import Middleware, Next
|
|
23
|
+
from virtus.request import Request
|
|
24
|
+
from virtus.tool import Tool
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from virtus.agent import Agent
|
|
28
|
+
from virtus.thread import Conversation
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Target:
|
|
33
|
+
"""An agent the model can address: `conversation` gives the thread behind `name` or `name:thread`."""
|
|
34
|
+
|
|
35
|
+
agent: Agent[..., object]
|
|
36
|
+
conversation: Callable[[Conversation, str | None], Conversation]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Failed:
|
|
41
|
+
"""What a failing address answers with, so the other addresses' replies still come back."""
|
|
42
|
+
|
|
43
|
+
error: str
|
|
44
|
+
|
|
45
|
+
def as_reply(self) -> dict[str, str]:
|
|
46
|
+
return {"error": self.error}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class Dispatch(Tool):
|
|
51
|
+
targets: tuple[Target, ...]
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def to(cls, targets: tuple[Target, ...], parent: Conversation) -> Dispatch:
|
|
55
|
+
by_name = {target.agent.name: target for target in targets}
|
|
56
|
+
|
|
57
|
+
async def dispatch(to: list[str], arguments: dict[str, object]) -> dict[str, object]:
|
|
58
|
+
if unknown := [address for address in to if address.partition(":")[0] not in by_name]:
|
|
59
|
+
raise ValueError(f"unknown agents: {', '.join(unknown)}; known: {', '.join(by_name)}")
|
|
60
|
+
|
|
61
|
+
async def deliver(address: str) -> object | Failed:
|
|
62
|
+
"""The agent's reply, or `Failed`, so one address failing doesn't lose the others' replies."""
|
|
63
|
+
name, _, thread = address.partition(":")
|
|
64
|
+
target = by_name[name]
|
|
65
|
+
try:
|
|
66
|
+
values = dict(target.agent.parameters.model_validate(arguments))
|
|
67
|
+
return await target.conversation(parent, thread or None).send(target.agent.input(**values))
|
|
68
|
+
except Exception as error:
|
|
69
|
+
return Failed(f"{type(error).__name__}: {error}")
|
|
70
|
+
|
|
71
|
+
replies = dict(zip(to, await asyncio.gather(*(deliver(address) for address in to)), strict=True))
|
|
72
|
+
failed = {address: reply for address, reply in replies.items() if isinstance(reply, Failed)}
|
|
73
|
+
if failed and len(failed) == len(replies):
|
|
74
|
+
raise ValueError("\n".join(f"{address}: {reply.error}" for address, reply in failed.items()))
|
|
75
|
+
return {address: r.as_reply() if isinstance(r, Failed) else r for address, r in replies.items()}
|
|
76
|
+
|
|
77
|
+
return cls("dispatch", DESCRIPTION, PARAMETERS, dispatch, targets)
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def schema(self) -> Mapping[str, object]:
|
|
81
|
+
"""The parameters schema with `arguments` spelled out as one of the targets' templates."""
|
|
82
|
+
union = reduce(or_, (target.agent.parameters for target in self.targets))
|
|
83
|
+
arguments = TypeAdapter[object](union).json_schema()
|
|
84
|
+
schema = PARAMETERS.model_json_schema()
|
|
85
|
+
defs = {**schema.get("$defs", {}), **arguments.pop("$defs", {})}
|
|
86
|
+
properties = {**schema["properties"], "arguments": {**schema["properties"]["arguments"], **arguments}}
|
|
87
|
+
return {**schema, "properties": properties, **({"$defs": defs} if defs else {})}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def dispatching(target: Target) -> Middleware:
|
|
91
|
+
"""Middleware adding `target` to the request's `dispatch` tool, creating the tool if it is the first.
|
|
92
|
+
|
|
93
|
+
An agent with an `Image` or `Audio` parameter is refused: the model has no way to pass media.
|
|
94
|
+
"""
|
|
95
|
+
fields = target.agent.parameters.model_fields.items()
|
|
96
|
+
if media := [name for name, field in fields if _media(field.annotation)]:
|
|
97
|
+
names = ", ".join(media)
|
|
98
|
+
raise TypeError(f"{target.agent.name}: can't be dispatched to; the model can't fill {names} with media")
|
|
99
|
+
|
|
100
|
+
async def add(request: Request, next: Next) -> AsyncIterator[Event]:
|
|
101
|
+
parent = request.thread
|
|
102
|
+
found = next_dispatch(request)
|
|
103
|
+
tool = Dispatch.to((*(found.targets if found else ()), target), parent)
|
|
104
|
+
tools = (*(t for t in request.tools if t is not found), tool)
|
|
105
|
+
|
|
106
|
+
name = target.agent.name
|
|
107
|
+
open = [thread for agent, thread in parent.children if agent == name and not ONE_OFF.fullmatch(thread)]
|
|
108
|
+
line = f"- {name}: {target.agent.description}" + (f" Open conversations: {', '.join(open)}" if open else "")
|
|
109
|
+
if found:
|
|
110
|
+
system = f"{request.system}\n{line}"
|
|
111
|
+
else:
|
|
112
|
+
system = "\n\n".join(part for part in (request.system, f"{HEADER}\n{line}") if part)
|
|
113
|
+
|
|
114
|
+
async for event in next(replace(request, system=system, tools=tools)):
|
|
115
|
+
yield event
|
|
116
|
+
|
|
117
|
+
return add
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def next_dispatch(request: Request) -> Dispatch | None:
|
|
121
|
+
return next((tool for tool in request.tools if isinstance(tool, Dispatch)), None)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _media(annotation: object) -> bool:
|
|
125
|
+
return annotation in (Image, Audio) or any(_media(inner) for inner in get_args(annotation))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
HEADER = "Agents you can `dispatch` to:"
|
|
129
|
+
|
|
130
|
+
ONE_OFF = re.compile(r"[0-9a-f]{32}")
|
|
131
|
+
"""The generated name of a one-off request's thread, kept in the caller's journal but not offered to the model."""
|
|
132
|
+
|
|
133
|
+
DESCRIPTION = (
|
|
134
|
+
"Send a request to one or more agents and get their replies, keyed by address. An address is an agent "
|
|
135
|
+
"name for a one-off request, or `agent:conversation` to continue (or start) a named conversation with "
|
|
136
|
+
"that agent, which then remembers earlier requests. `arguments` are the addressed agent's; one call "
|
|
137
|
+
"sends the same arguments to every address, at once."
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
PARAMETERS = create_model(
|
|
141
|
+
"dispatch",
|
|
142
|
+
to=(list[str], Field(description="Addresses: `agent` or `agent:conversation`.", min_length=1)),
|
|
143
|
+
arguments=(dict[str, object], Field(description="The arguments of the addressed agent.")),
|
|
144
|
+
)
|