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