moss-agent 1.0.0__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.
- moss_agent-1.0.0/LICENSE.txt +168 -0
- moss_agent-1.0.0/PKG-INFO +108 -0
- moss_agent-1.0.0/README.md +75 -0
- moss_agent-1.0.0/pyproject.toml +73 -0
- moss_agent-1.0.0/setup.cfg +4 -0
- moss_agent-1.0.0/src/moss_agent/__init__.py +82 -0
- moss_agent-1.0.0/src/moss_agent/client.py +404 -0
- moss_agent-1.0.0/src/moss_agent/py.typed +0 -0
- moss_agent-1.0.0/src/moss_agent.egg-info/PKG-INFO +108 -0
- moss_agent-1.0.0/src/moss_agent.egg-info/SOURCES.txt +12 -0
- moss_agent-1.0.0/src/moss_agent.egg-info/dependency_links.txt +1 -0
- moss_agent-1.0.0/src/moss_agent.egg-info/requires.txt +12 -0
- moss_agent-1.0.0/src/moss_agent.egg-info/top_level.txt +1 -0
- moss_agent-1.0.0/tests/test_client.py +304 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
Copyright (c) 2025 InferEdge Inc.
|
|
2
|
+
Required Notice: Copyright InferEdge Inc. (https://inferedge.dev)
|
|
3
|
+
Licensor Line of Business: InferEdge MOSS Runtime (https://inferedge.dev)
|
|
4
|
+
|
|
5
|
+
# PolyForm Shield License 1.0.0
|
|
6
|
+
|
|
7
|
+
<https://polyformproject.org/licenses/shield/1.0.0>
|
|
8
|
+
|
|
9
|
+
## Acceptance
|
|
10
|
+
|
|
11
|
+
In order to get any license under these terms, you must agree
|
|
12
|
+
to them as both strict obligations and conditions to all
|
|
13
|
+
your licenses.
|
|
14
|
+
|
|
15
|
+
## Copyright License
|
|
16
|
+
|
|
17
|
+
The licensor grants you a copyright license for the
|
|
18
|
+
software to do everything you might do with the software
|
|
19
|
+
that would otherwise infringe the licensor's copyright
|
|
20
|
+
in it for any permitted purpose. However, you may
|
|
21
|
+
only distribute the software according to [Distribution
|
|
22
|
+
License](#distribution-license) and make changes or new works
|
|
23
|
+
based on the software according to [Changes and New Works
|
|
24
|
+
License](#changes-and-new-works-license).
|
|
25
|
+
|
|
26
|
+
## Distribution License
|
|
27
|
+
|
|
28
|
+
The licensor grants you an additional copyright license
|
|
29
|
+
to distribute copies of the software. Your license
|
|
30
|
+
to distribute covers distributing the software with
|
|
31
|
+
changes and new works permitted by [Changes and New Works
|
|
32
|
+
License](#changes-and-new-works-license).
|
|
33
|
+
|
|
34
|
+
## Notices
|
|
35
|
+
|
|
36
|
+
You must ensure that anyone who gets a copy of any part of
|
|
37
|
+
the software from you also gets a copy of these terms or the
|
|
38
|
+
URL for them above, as well as copies of any plain-text lines
|
|
39
|
+
beginning with `Required Notice:` that the licensor provided
|
|
40
|
+
with the software. For example:
|
|
41
|
+
|
|
42
|
+
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
|
|
43
|
+
|
|
44
|
+
## Changes and New Works License
|
|
45
|
+
|
|
46
|
+
The licensor grants you an additional copyright license to
|
|
47
|
+
make changes and new works based on the software for any
|
|
48
|
+
permitted purpose.
|
|
49
|
+
|
|
50
|
+
## Patent License
|
|
51
|
+
|
|
52
|
+
The licensor grants you a patent license for the software that
|
|
53
|
+
covers patent claims the licensor can license, or becomes able
|
|
54
|
+
to license, that you would infringe by using the software.
|
|
55
|
+
|
|
56
|
+
## Noncompete
|
|
57
|
+
|
|
58
|
+
Any purpose is a permitted purpose, except for providing any
|
|
59
|
+
product that competes with the software or any product the
|
|
60
|
+
licensor or any of its affiliates provides using the software.
|
|
61
|
+
|
|
62
|
+
## Competition
|
|
63
|
+
|
|
64
|
+
Goods and services compete even when they provide functionality
|
|
65
|
+
through different kinds of interfaces or for different technical
|
|
66
|
+
platforms. Applications can compete with services, libraries
|
|
67
|
+
with plugins, frameworks with development tools, and so on,
|
|
68
|
+
even if they're written in different programming languages
|
|
69
|
+
or for different computer architectures. Goods and services
|
|
70
|
+
compete even when provided free of charge. If you market a
|
|
71
|
+
product as a practical substitute for the software or another
|
|
72
|
+
product, it definitely competes.
|
|
73
|
+
|
|
74
|
+
## New Products
|
|
75
|
+
|
|
76
|
+
If you are using the software to provide a product that does
|
|
77
|
+
not compete, but the licensor or any of its affiliates brings
|
|
78
|
+
your product into competition by providing a new version of
|
|
79
|
+
the software or another product using the software, you may
|
|
80
|
+
continue using versions of the software available under these
|
|
81
|
+
terms beforehand to provide your competing product, but not
|
|
82
|
+
any later versions.
|
|
83
|
+
|
|
84
|
+
## Discontinued Products
|
|
85
|
+
|
|
86
|
+
You may begin using the software to compete with a product
|
|
87
|
+
or service that the licensor or any of its affiliates has
|
|
88
|
+
stopped providing, unless the licensor includes a plain-text
|
|
89
|
+
line beginning with `Licensor Line of Business:` with the
|
|
90
|
+
software that mentions that line of business. For example:
|
|
91
|
+
|
|
92
|
+
> Licensor Line of Business: YoyodyneCMS Content Management
|
|
93
|
+
System (http://example.com/cms)
|
|
94
|
+
|
|
95
|
+
## Sales of Business
|
|
96
|
+
|
|
97
|
+
If the licensor or any of its affiliates sells a line of
|
|
98
|
+
business developing the software or using the software
|
|
99
|
+
to provide a product, the buyer can also enforce
|
|
100
|
+
[Noncompete](#noncompete) for that product.
|
|
101
|
+
|
|
102
|
+
## Fair Use
|
|
103
|
+
|
|
104
|
+
You may have "fair use" rights for the software under the
|
|
105
|
+
law. These terms do not limit them.
|
|
106
|
+
|
|
107
|
+
## No Other Rights
|
|
108
|
+
|
|
109
|
+
These terms do not allow you to sublicense or transfer any of
|
|
110
|
+
your licenses to anyone else, or prevent the licensor from
|
|
111
|
+
granting licenses to anyone else. These terms do not imply
|
|
112
|
+
any other licenses.
|
|
113
|
+
|
|
114
|
+
## Patent Defense
|
|
115
|
+
|
|
116
|
+
If you make any written claim that the software infringes or
|
|
117
|
+
contributes to infringement of any patent, your patent license
|
|
118
|
+
for the software granted under these terms ends immediately. If
|
|
119
|
+
your company makes such a claim, your patent license ends
|
|
120
|
+
immediately for work on behalf of your company.
|
|
121
|
+
|
|
122
|
+
## Violations
|
|
123
|
+
|
|
124
|
+
The first time you are notified in writing that you have
|
|
125
|
+
violated any of these terms, or done anything with the software
|
|
126
|
+
not covered by your licenses, your licenses can nonetheless
|
|
127
|
+
continue if you come into full compliance with these terms,
|
|
128
|
+
and take practical steps to correct past violations, within
|
|
129
|
+
32 days of receiving notice. Otherwise, all your licenses
|
|
130
|
+
end immediately.
|
|
131
|
+
|
|
132
|
+
## No Liability
|
|
133
|
+
|
|
134
|
+
***As far as the law allows, the software comes as is, without
|
|
135
|
+
any warranty or condition, and the licensor will not be liable
|
|
136
|
+
to you for any damages arising out of these terms or the use
|
|
137
|
+
or nature of the software, under any kind of legal claim.***
|
|
138
|
+
|
|
139
|
+
## Definitions
|
|
140
|
+
|
|
141
|
+
The **licensor** is the individual or entity offering these
|
|
142
|
+
terms, and the **software** is the software the licensor makes
|
|
143
|
+
available under these terms.
|
|
144
|
+
|
|
145
|
+
A **product** can be a good or service, or a combination
|
|
146
|
+
of them.
|
|
147
|
+
|
|
148
|
+
**You** refers to the individual or entity agreeing to these
|
|
149
|
+
terms.
|
|
150
|
+
|
|
151
|
+
**Your company** is any legal entity, sole proprietorship,
|
|
152
|
+
or other kind of organization that you work for, plus all
|
|
153
|
+
its affiliates.
|
|
154
|
+
|
|
155
|
+
**Affiliates** means the other organizations than an
|
|
156
|
+
organization has control over, is under the control of, or is
|
|
157
|
+
under common control with.
|
|
158
|
+
|
|
159
|
+
**Control** means ownership of substantially all the assets of
|
|
160
|
+
an entity, or the power to direct its management and policies
|
|
161
|
+
by vote, contract, or otherwise. Control can be direct or
|
|
162
|
+
indirect.
|
|
163
|
+
|
|
164
|
+
**Your licenses** are all the licenses granted to you for the
|
|
165
|
+
software under these terms.
|
|
166
|
+
|
|
167
|
+
**Use** means anything you do with the software requiring one
|
|
168
|
+
of your licenses.
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: moss-agent
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Moss runtime for LiveKit voice agents - a hot index cache shared across rooms and a one-line attach per call.
|
|
5
|
+
Author-email: "InferEdge Inc." <contact@usemoss.dev>
|
|
6
|
+
Project-URL: Homepage, https://github.com/usemoss/moss-samples
|
|
7
|
+
Project-URL: Repository, https://github.com/usemoss/moss-samples
|
|
8
|
+
Project-URL: Documentation, https://docs.usemoss.dev/
|
|
9
|
+
Keywords: livekit,voice-agent,semantic-search,moss,usemoss
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE.txt
|
|
22
|
+
Requires-Dist: typing-extensions>=4.0.0
|
|
23
|
+
Requires-Dist: inferedge-moss-core==0.12.0
|
|
24
|
+
Provides-Extra: livekit
|
|
25
|
+
Requires-Dist: livekit-agents>=0.10.0; extra == "livekit"
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=8.4.2; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-asyncio>=1.2.0; extra == "dev"
|
|
29
|
+
Requires-Dist: black>=25.9.0; extra == "dev"
|
|
30
|
+
Requires-Dist: isort>=7.0.0; extra == "dev"
|
|
31
|
+
Requires-Dist: mypy>=1.18.2; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# moss-agent
|
|
35
|
+
|
|
36
|
+
Moss runtime for LiveKit voice agents - a hot index cache shared across rooms and a one-line attach per call.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install moss-agent
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Use - voice (LiveKit)
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from livekit.agents import AgentServer, JobContext, JobProcess
|
|
48
|
+
from moss_agent import MossAgent
|
|
49
|
+
|
|
50
|
+
server = AgentServer()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def prewarm(proc: JobProcess) -> None:
|
|
54
|
+
agent = MossAgent(
|
|
55
|
+
project_id="...",
|
|
56
|
+
project_key="...",
|
|
57
|
+
)
|
|
58
|
+
import asyncio
|
|
59
|
+
asyncio.run(agent.load_indexes(["product_catalog", "faq", "policies"]))
|
|
60
|
+
proc.userdata["moss_agent"] = agent
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
server.setup_fnc = prewarm
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@server.rtc_session(agent_name="my-agent")
|
|
67
|
+
async def handle_visit(ctx: JobContext) -> None:
|
|
68
|
+
await ctx.connect()
|
|
69
|
+
|
|
70
|
+
agent: MossAgent = ctx.proc.userdata["moss_agent"]
|
|
71
|
+
call = agent.attach(ctx)
|
|
72
|
+
|
|
73
|
+
results = await call.query("product_catalog", user_question)
|
|
74
|
+
# ... your existing agent loop ...
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Use - text (HTTP / chat / non-LiveKit)
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from moss_agent import MossAgent
|
|
81
|
+
|
|
82
|
+
# Module level - reuse across requests
|
|
83
|
+
agent = MossAgent(project_id="...", project_key="...")
|
|
84
|
+
await agent.load_indexes(["product_catalog"])
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# In your HTTP / chat handler
|
|
88
|
+
async def handle_message(req):
|
|
89
|
+
results = await agent.query("product_catalog", req.user_message)
|
|
90
|
+
# ... feed results to your model ...
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## API surface
|
|
94
|
+
|
|
95
|
+
- `MossAgent(project_id, project_key)` - process-wide instance. Build once, share across every room (voice) or every request (text).
|
|
96
|
+
- **Voice:** `MossAgent.attach(ctx) -> MossCall` binds a Moss call scope to a LiveKit `JobContext`. Idempotent on `ctx.room.name`. `MossCall.query(name, query, options=None)` and `MossCall.query_multi_index(names, query, options=None)` are the call-scoped query surface.
|
|
97
|
+
- **Text:** `MossAgent.query(name, query, options=None)` and `MossAgent.query_multi_index(names, query, options=None)`.
|
|
98
|
+
- Full index CRUD (`create_index`, `add_docs`, `delete_docs`, `delete_index`, `list_indexes`, `get_index`, `get_docs`, `get_job_status`) and cache lifecycle (`load_index`, `load_indexes`, `unload_index`, `unload_indexes`) on `MossAgent`.
|
|
99
|
+
|
|
100
|
+
## Requirements
|
|
101
|
+
|
|
102
|
+
- Python 3.10+
|
|
103
|
+
- `inferedge-moss-core == 0.12.0`
|
|
104
|
+
- `livekit-agents >= 0.10.0` (only required if you call `attach()`; install via the `[livekit]` extra)
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
Proprietary. See `LICENSE.txt`.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# moss-agent
|
|
2
|
+
|
|
3
|
+
Moss runtime for LiveKit voice agents - a hot index cache shared across rooms and a one-line attach per call.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install moss-agent
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Use - voice (LiveKit)
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from livekit.agents import AgentServer, JobContext, JobProcess
|
|
15
|
+
from moss_agent import MossAgent
|
|
16
|
+
|
|
17
|
+
server = AgentServer()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def prewarm(proc: JobProcess) -> None:
|
|
21
|
+
agent = MossAgent(
|
|
22
|
+
project_id="...",
|
|
23
|
+
project_key="...",
|
|
24
|
+
)
|
|
25
|
+
import asyncio
|
|
26
|
+
asyncio.run(agent.load_indexes(["product_catalog", "faq", "policies"]))
|
|
27
|
+
proc.userdata["moss_agent"] = agent
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
server.setup_fnc = prewarm
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@server.rtc_session(agent_name="my-agent")
|
|
34
|
+
async def handle_visit(ctx: JobContext) -> None:
|
|
35
|
+
await ctx.connect()
|
|
36
|
+
|
|
37
|
+
agent: MossAgent = ctx.proc.userdata["moss_agent"]
|
|
38
|
+
call = agent.attach(ctx)
|
|
39
|
+
|
|
40
|
+
results = await call.query("product_catalog", user_question)
|
|
41
|
+
# ... your existing agent loop ...
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Use - text (HTTP / chat / non-LiveKit)
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from moss_agent import MossAgent
|
|
48
|
+
|
|
49
|
+
# Module level - reuse across requests
|
|
50
|
+
agent = MossAgent(project_id="...", project_key="...")
|
|
51
|
+
await agent.load_indexes(["product_catalog"])
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# In your HTTP / chat handler
|
|
55
|
+
async def handle_message(req):
|
|
56
|
+
results = await agent.query("product_catalog", req.user_message)
|
|
57
|
+
# ... feed results to your model ...
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## API surface
|
|
61
|
+
|
|
62
|
+
- `MossAgent(project_id, project_key)` - process-wide instance. Build once, share across every room (voice) or every request (text).
|
|
63
|
+
- **Voice:** `MossAgent.attach(ctx) -> MossCall` binds a Moss call scope to a LiveKit `JobContext`. Idempotent on `ctx.room.name`. `MossCall.query(name, query, options=None)` and `MossCall.query_multi_index(names, query, options=None)` are the call-scoped query surface.
|
|
64
|
+
- **Text:** `MossAgent.query(name, query, options=None)` and `MossAgent.query_multi_index(names, query, options=None)`.
|
|
65
|
+
- Full index CRUD (`create_index`, `add_docs`, `delete_docs`, `delete_index`, `list_indexes`, `get_index`, `get_docs`, `get_job_status`) and cache lifecycle (`load_index`, `load_indexes`, `unload_index`, `unload_indexes`) on `MossAgent`.
|
|
66
|
+
|
|
67
|
+
## Requirements
|
|
68
|
+
|
|
69
|
+
- Python 3.10+
|
|
70
|
+
- `inferedge-moss-core == 0.12.0`
|
|
71
|
+
- `livekit-agents >= 0.10.0` (only required if you call `attach()`; install via the `[livekit]` extra)
|
|
72
|
+
|
|
73
|
+
## License
|
|
74
|
+
|
|
75
|
+
Proprietary. See `LICENSE.txt`.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "moss-agent"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Moss runtime for LiveKit voice agents - a hot index cache shared across rooms and a one-line attach per call."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license-files = ["LICENSE.txt"]
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "InferEdge Inc.", email = "contact@usemoss.dev" }
|
|
13
|
+
]
|
|
14
|
+
keywords = ["livekit", "voice-agent", "semantic-search", "moss", "usemoss"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 5 - Production/Stable",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
24
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
25
|
+
]
|
|
26
|
+
requires-python = ">=3.10"
|
|
27
|
+
dependencies = [
|
|
28
|
+
"typing-extensions>=4.0.0",
|
|
29
|
+
"inferedge-moss-core==0.12.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
livekit = [
|
|
34
|
+
"livekit-agents>=0.10.0",
|
|
35
|
+
]
|
|
36
|
+
dev = [
|
|
37
|
+
"pytest>=8.4.2",
|
|
38
|
+
"pytest-asyncio>=1.2.0",
|
|
39
|
+
"black>=25.9.0",
|
|
40
|
+
"isort>=7.0.0",
|
|
41
|
+
"mypy>=1.18.2",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[project.urls]
|
|
45
|
+
Homepage = "https://github.com/usemoss/moss-samples"
|
|
46
|
+
Repository = "https://github.com/usemoss/moss-samples"
|
|
47
|
+
Documentation = "https://docs.usemoss.dev/"
|
|
48
|
+
|
|
49
|
+
[tool.setuptools.packages.find]
|
|
50
|
+
where = ["src"]
|
|
51
|
+
|
|
52
|
+
[tool.setuptools.package-dir]
|
|
53
|
+
"" = "src"
|
|
54
|
+
|
|
55
|
+
[tool.setuptools.package-data]
|
|
56
|
+
"moss_agent" = [
|
|
57
|
+
"py.typed",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
[tool.black]
|
|
61
|
+
line-length = 88
|
|
62
|
+
target-version = ['py310']
|
|
63
|
+
|
|
64
|
+
[tool.isort]
|
|
65
|
+
profile = "black"
|
|
66
|
+
line_length = 88
|
|
67
|
+
|
|
68
|
+
[tool.mypy]
|
|
69
|
+
python_version = "3.10"
|
|
70
|
+
warn_return_any = false
|
|
71
|
+
warn_unused_configs = true
|
|
72
|
+
disallow_untyped_defs = true
|
|
73
|
+
ignore_missing_imports = true
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Moss Agent SDK - Moss for LiveKit voice agents.
|
|
3
|
+
|
|
4
|
+
A single :class:`MossAgent` lives at the process level, holding a hot
|
|
5
|
+
index cache that every room the worker handles queries against - no
|
|
6
|
+
per-call cold start, no duplicated state, one warm in-memory index
|
|
7
|
+
serving hundreds of concurrent rooms.
|
|
8
|
+
|
|
9
|
+
Two query surfaces share the same agent:
|
|
10
|
+
|
|
11
|
+
* **Voice / LiveKit**: :meth:`MossAgent.attach` binds a
|
|
12
|
+
:class:`MossCall` to the room; queries via :meth:`MossCall.query`.
|
|
13
|
+
* **Text / HTTP / chat**: :meth:`MossAgent.query` directly.
|
|
14
|
+
|
|
15
|
+
Example - voice agent::
|
|
16
|
+
|
|
17
|
+
from moss_agent import MossAgent
|
|
18
|
+
|
|
19
|
+
# In prewarm():
|
|
20
|
+
agent = MossAgent("project-id", "project-key")
|
|
21
|
+
await agent.load_indexes(["kb1", "kb2"])
|
|
22
|
+
proc.userdata["moss_agent"] = agent
|
|
23
|
+
|
|
24
|
+
# In handle_visit(ctx):
|
|
25
|
+
await ctx.connect()
|
|
26
|
+
call = agent.attach(ctx)
|
|
27
|
+
results = await call.query("kb1", question)
|
|
28
|
+
|
|
29
|
+
Example - text agent::
|
|
30
|
+
|
|
31
|
+
agent = MossAgent("project-id", "project-key")
|
|
32
|
+
await agent.load_indexes(["kb1"])
|
|
33
|
+
|
|
34
|
+
# In an HTTP handler or chat message handler:
|
|
35
|
+
results = await agent.query("kb1", user_message)
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from moss_core import (
|
|
39
|
+
DocumentInfo,
|
|
40
|
+
GetDocumentsOptions,
|
|
41
|
+
IndexInfo,
|
|
42
|
+
IndexStatus,
|
|
43
|
+
IndexStatusValues,
|
|
44
|
+
JobPhase,
|
|
45
|
+
JobProgress,
|
|
46
|
+
JobStatus,
|
|
47
|
+
JobStatusResponse,
|
|
48
|
+
LoadIndexesResult,
|
|
49
|
+
ModelRef,
|
|
50
|
+
MutationOptions,
|
|
51
|
+
MutationResult,
|
|
52
|
+
QueryOptions,
|
|
53
|
+
QueryResultDocumentInfo,
|
|
54
|
+
SearchResult,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
from .client import MossAgent, MossCall, ParseFileInput
|
|
58
|
+
|
|
59
|
+
__version__ = "1.0.0"
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
"MossAgent",
|
|
63
|
+
"MossCall",
|
|
64
|
+
"ParseFileInput",
|
|
65
|
+
# Re-exports from moss_core
|
|
66
|
+
"DocumentInfo",
|
|
67
|
+
"GetDocumentsOptions",
|
|
68
|
+
"IndexInfo",
|
|
69
|
+
"IndexStatus",
|
|
70
|
+
"IndexStatusValues",
|
|
71
|
+
"JobPhase",
|
|
72
|
+
"JobProgress",
|
|
73
|
+
"JobStatus",
|
|
74
|
+
"JobStatusResponse",
|
|
75
|
+
"LoadIndexesResult",
|
|
76
|
+
"ModelRef",
|
|
77
|
+
"MutationOptions",
|
|
78
|
+
"MutationResult",
|
|
79
|
+
"QueryOptions",
|
|
80
|
+
"QueryResultDocumentInfo",
|
|
81
|
+
"SearchResult",
|
|
82
|
+
]
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Python wrappers over the Rust ``moss_core`` Agent + CallScope.
|
|
3
|
+
|
|
4
|
+
This module is intentionally thin: each method either delegates to the
|
|
5
|
+
underlying Rust binding via :func:`asyncio.to_thread` (so the sync Rust
|
|
6
|
+
call doesn't block the asyncio event loop) or unpacks framework-specific
|
|
7
|
+
inputs (a LiveKit ``JobContext``) into the opaque correlation strings the
|
|
8
|
+
Rust layer expects.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any, List, Optional
|
|
16
|
+
|
|
17
|
+
from moss_core import (
|
|
18
|
+
Agent as _Agent,
|
|
19
|
+
DocumentInfo,
|
|
20
|
+
GetDocumentsOptions,
|
|
21
|
+
IndexInfo,
|
|
22
|
+
JobStatusResponse,
|
|
23
|
+
LoadIndexesResult,
|
|
24
|
+
MutationOptions,
|
|
25
|
+
MutationResult,
|
|
26
|
+
QueryOptions,
|
|
27
|
+
SearchResult,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class ParseFileInput:
|
|
33
|
+
"""
|
|
34
|
+
Input descriptor for a single file in the parse pipeline.
|
|
35
|
+
|
|
36
|
+
Either ``path`` (filesystem path) or ``data`` (raw bytes) must be
|
|
37
|
+
provided. Both ``name`` and ``content_type`` are required. Only
|
|
38
|
+
``"application/pdf"`` is currently supported as ``content_type``.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
name: str
|
|
42
|
+
content_type: str
|
|
43
|
+
path: Optional[str] = None
|
|
44
|
+
data: Optional[bytes] = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class MossAgent:
|
|
48
|
+
"""
|
|
49
|
+
Process-wide Moss runtime for an agent process.
|
|
50
|
+
|
|
51
|
+
Owns the local index cache and auth. One per process - typically
|
|
52
|
+
constructed in LiveKit's ``prewarm()`` and stashed on
|
|
53
|
+
``proc.userdata`` for every room handler to share.
|
|
54
|
+
|
|
55
|
+
Two query surfaces, one object:
|
|
56
|
+
|
|
57
|
+
* Voice / LiveKit: call :meth:`attach` with a ``JobContext`` to get a
|
|
58
|
+
:class:`MossCall`, then use :meth:`MossCall.query`.
|
|
59
|
+
|
|
60
|
+
* Text / HTTP / chat: call :meth:`query` (or :meth:`query_multi_index`)
|
|
61
|
+
directly on the agent.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
DEFAULT_MODEL_ID = "moss-minilm"
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
project_id: str,
|
|
69
|
+
project_key: str,
|
|
70
|
+
base_url: Optional[str] = None,
|
|
71
|
+
client_id: Optional[str] = None,
|
|
72
|
+
) -> None:
|
|
73
|
+
self._inner = _Agent(
|
|
74
|
+
project_id,
|
|
75
|
+
project_key,
|
|
76
|
+
base_url=base_url,
|
|
77
|
+
client_id=client_id,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def client_id(self) -> str:
|
|
82
|
+
return self._inner.client_id()
|
|
83
|
+
|
|
84
|
+
async def active_call_count(self) -> int:
|
|
85
|
+
"""Number of currently-active call scopes (across all rooms)."""
|
|
86
|
+
return await asyncio.to_thread(self._inner.active_call_count)
|
|
87
|
+
|
|
88
|
+
# -- Index CRUD ---------------------------------------------------
|
|
89
|
+
|
|
90
|
+
async def create_index(
|
|
91
|
+
self,
|
|
92
|
+
name: str,
|
|
93
|
+
docs: List[DocumentInfo],
|
|
94
|
+
model_id: Optional[str] = None,
|
|
95
|
+
) -> MutationResult:
|
|
96
|
+
resolved = self._resolve_model_id(docs, model_id)
|
|
97
|
+
return await asyncio.to_thread(self._inner.create_index, name, docs, resolved)
|
|
98
|
+
|
|
99
|
+
async def create_index_from_files(
|
|
100
|
+
self,
|
|
101
|
+
name: str,
|
|
102
|
+
files: List[ParseFileInput],
|
|
103
|
+
model_id: Optional[str] = None,
|
|
104
|
+
) -> MutationResult:
|
|
105
|
+
resolved = model_id or self.DEFAULT_MODEL_ID
|
|
106
|
+
if resolved == "custom":
|
|
107
|
+
raise ValueError(
|
|
108
|
+
"create_index_from_files does not support model_id='custom' - "
|
|
109
|
+
"the parse pipeline generates embeddings server-side."
|
|
110
|
+
)
|
|
111
|
+
from moss_core import ParseFileInput as CoreParseFileInput
|
|
112
|
+
|
|
113
|
+
core_files = [
|
|
114
|
+
CoreParseFileInput(f.name, f.content_type, path=f.path, data=f.data)
|
|
115
|
+
for f in files
|
|
116
|
+
]
|
|
117
|
+
return await asyncio.to_thread(
|
|
118
|
+
self._inner.create_index_from_files, name, core_files, resolved
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
async def add_docs(
|
|
122
|
+
self,
|
|
123
|
+
name: str,
|
|
124
|
+
docs: List[DocumentInfo],
|
|
125
|
+
options: Optional[MutationOptions] = None,
|
|
126
|
+
) -> MutationResult:
|
|
127
|
+
return await asyncio.to_thread(self._inner.add_docs, name, docs, options)
|
|
128
|
+
|
|
129
|
+
async def delete_docs(self, name: str, doc_ids: List[str]) -> MutationResult:
|
|
130
|
+
return await asyncio.to_thread(self._inner.delete_docs, name, doc_ids)
|
|
131
|
+
|
|
132
|
+
async def delete_index(self, name: str) -> bool:
|
|
133
|
+
return await asyncio.to_thread(self._inner.delete_index, name)
|
|
134
|
+
|
|
135
|
+
async def list_indexes(self) -> List[IndexInfo]:
|
|
136
|
+
return await asyncio.to_thread(self._inner.list_indexes)
|
|
137
|
+
|
|
138
|
+
async def get_index(self, name: str) -> IndexInfo:
|
|
139
|
+
return await asyncio.to_thread(self._inner.get_index, name)
|
|
140
|
+
|
|
141
|
+
async def get_docs(
|
|
142
|
+
self,
|
|
143
|
+
name: str,
|
|
144
|
+
options: Optional[GetDocumentsOptions] = None,
|
|
145
|
+
) -> List[DocumentInfo]:
|
|
146
|
+
return await asyncio.to_thread(self._inner.get_docs, name, options)
|
|
147
|
+
|
|
148
|
+
async def get_job_status(self, job_id: str) -> JobStatusResponse:
|
|
149
|
+
return await asyncio.to_thread(self._inner.get_job_status, job_id)
|
|
150
|
+
|
|
151
|
+
# -- Index lifecycle ----------------------------------------------
|
|
152
|
+
|
|
153
|
+
async def load_index(
|
|
154
|
+
self,
|
|
155
|
+
name: str,
|
|
156
|
+
auto_refresh: bool = False,
|
|
157
|
+
polling_interval_in_seconds: int = 600,
|
|
158
|
+
cache_path: Optional[str] = None,
|
|
159
|
+
) -> IndexInfo:
|
|
160
|
+
return await asyncio.to_thread(
|
|
161
|
+
self._inner.load_index,
|
|
162
|
+
name,
|
|
163
|
+
auto_refresh,
|
|
164
|
+
polling_interval_in_seconds,
|
|
165
|
+
cache_path,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
async def load_indexes(
|
|
169
|
+
self,
|
|
170
|
+
names: List[str],
|
|
171
|
+
auto_refresh: bool = False,
|
|
172
|
+
polling_interval_in_seconds: int = 600,
|
|
173
|
+
cache_path: Optional[str] = None,
|
|
174
|
+
) -> LoadIndexesResult:
|
|
175
|
+
return await asyncio.to_thread(
|
|
176
|
+
self._inner.load_indexes,
|
|
177
|
+
names,
|
|
178
|
+
auto_refresh,
|
|
179
|
+
polling_interval_in_seconds,
|
|
180
|
+
cache_path,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
async def unload_index(self, name: str) -> None:
|
|
184
|
+
await asyncio.to_thread(self._inner.unload_index, name)
|
|
185
|
+
|
|
186
|
+
async def unload_indexes(self, names: List[str]) -> None:
|
|
187
|
+
await asyncio.to_thread(self._inner.unload_indexes, names)
|
|
188
|
+
|
|
189
|
+
# -- Call lifecycle -----------------------------------------------
|
|
190
|
+
|
|
191
|
+
def attach(self, ctx: Any) -> "MossCall":
|
|
192
|
+
"""
|
|
193
|
+
Bind a Moss call scope to a LiveKit ``JobContext``.
|
|
194
|
+
|
|
195
|
+
Idempotent on ``ctx.room.name`` - calling ``attach`` twice for the
|
|
196
|
+
same room returns scopes pointing at the same underlying call.
|
|
197
|
+
"""
|
|
198
|
+
room = ctx.room
|
|
199
|
+
room_id: str = room.name
|
|
200
|
+
session_id: Optional[str] = None
|
|
201
|
+
job = getattr(ctx, "job", None)
|
|
202
|
+
if job is not None:
|
|
203
|
+
session_id = getattr(job, "id", None)
|
|
204
|
+
|
|
205
|
+
# `room.creation_time` is a UTC datetime; convert to ms-since-epoch.
|
|
206
|
+
creation_dt = room.creation_time
|
|
207
|
+
start_ms = int(creation_dt.timestamp() * 1000)
|
|
208
|
+
|
|
209
|
+
scope = self._inner.start_call(
|
|
210
|
+
room_id,
|
|
211
|
+
start_ms,
|
|
212
|
+
livekit_session_id=session_id,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
call = MossCall(scope)
|
|
216
|
+
|
|
217
|
+
async def _on_shutdown(reason: Optional[str] = None) -> None:
|
|
218
|
+
await asyncio.to_thread(scope.end)
|
|
219
|
+
|
|
220
|
+
ctx.add_shutdown_callback(_on_shutdown)
|
|
221
|
+
return call
|
|
222
|
+
|
|
223
|
+
# -- Text-mode queries --------------------------------------------
|
|
224
|
+
|
|
225
|
+
async def query(
|
|
226
|
+
self,
|
|
227
|
+
name: str,
|
|
228
|
+
query: str,
|
|
229
|
+
*,
|
|
230
|
+
options: Optional[QueryOptions] = None,
|
|
231
|
+
) -> SearchResult:
|
|
232
|
+
"""
|
|
233
|
+
Run a text-mode query.
|
|
234
|
+
|
|
235
|
+
For voice agents serving a LiveKit room, prefer
|
|
236
|
+
:meth:`attach` + :meth:`MossCall.query` instead.
|
|
237
|
+
"""
|
|
238
|
+
top_k = getattr(options, "top_k", None) or 5
|
|
239
|
+
alpha = getattr(options, "alpha", None) or 0.8
|
|
240
|
+
embedding = getattr(options, "embedding", None)
|
|
241
|
+
filter = getattr(options, "filter", None)
|
|
242
|
+
|
|
243
|
+
if embedding is None:
|
|
244
|
+
return await asyncio.to_thread(
|
|
245
|
+
self._inner.query_text,
|
|
246
|
+
name,
|
|
247
|
+
query,
|
|
248
|
+
top_k,
|
|
249
|
+
alpha,
|
|
250
|
+
filter,
|
|
251
|
+
)
|
|
252
|
+
return await asyncio.to_thread(
|
|
253
|
+
self._inner.query,
|
|
254
|
+
name,
|
|
255
|
+
query,
|
|
256
|
+
list(embedding),
|
|
257
|
+
top_k,
|
|
258
|
+
alpha,
|
|
259
|
+
filter,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
async def query_multi_index(
|
|
263
|
+
self,
|
|
264
|
+
names: List[str],
|
|
265
|
+
query: str,
|
|
266
|
+
*,
|
|
267
|
+
options: Optional[QueryOptions] = None,
|
|
268
|
+
) -> SearchResult:
|
|
269
|
+
"""
|
|
270
|
+
Text-mode multi-index search. ``options.alpha`` is ignored
|
|
271
|
+
(multi-index search is embedding-only).
|
|
272
|
+
"""
|
|
273
|
+
top_k = getattr(options, "top_k", None) or 10
|
|
274
|
+
embedding = getattr(options, "embedding", None)
|
|
275
|
+
filter = getattr(options, "filter", None)
|
|
276
|
+
|
|
277
|
+
if embedding is None:
|
|
278
|
+
return await asyncio.to_thread(
|
|
279
|
+
self._inner.query_multi_index_text,
|
|
280
|
+
names,
|
|
281
|
+
query,
|
|
282
|
+
top_k,
|
|
283
|
+
filter,
|
|
284
|
+
)
|
|
285
|
+
return await asyncio.to_thread(
|
|
286
|
+
self._inner.query_multi_index,
|
|
287
|
+
names,
|
|
288
|
+
query,
|
|
289
|
+
list(embedding),
|
|
290
|
+
top_k,
|
|
291
|
+
filter,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
# -- Internal ------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
def _resolve_model_id(
|
|
297
|
+
self, docs: List[DocumentInfo], model_id: Optional[str]
|
|
298
|
+
) -> str:
|
|
299
|
+
if model_id is not None:
|
|
300
|
+
return model_id
|
|
301
|
+
has_embeddings = any(
|
|
302
|
+
getattr(doc, "embedding", None) is not None for doc in docs
|
|
303
|
+
)
|
|
304
|
+
return "custom" if has_embeddings else self.DEFAULT_MODEL_ID
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class MossCall:
|
|
308
|
+
"""
|
|
309
|
+
Per-call handle returned by :meth:`MossAgent.attach`.
|
|
310
|
+
"""
|
|
311
|
+
|
|
312
|
+
def __init__(self, scope: Any) -> None:
|
|
313
|
+
self._scope = scope
|
|
314
|
+
|
|
315
|
+
@property
|
|
316
|
+
def call_id(self) -> str:
|
|
317
|
+
return self._scope.call_id
|
|
318
|
+
|
|
319
|
+
@property
|
|
320
|
+
def livekit_room_id(self) -> str:
|
|
321
|
+
return self._scope.livekit_room_id
|
|
322
|
+
|
|
323
|
+
@property
|
|
324
|
+
def livekit_session_id(self) -> Optional[str]:
|
|
325
|
+
return self._scope.livekit_session_id
|
|
326
|
+
|
|
327
|
+
@property
|
|
328
|
+
def start_at_unix_ms(self) -> int:
|
|
329
|
+
return self._scope.start_at_unix_ms
|
|
330
|
+
|
|
331
|
+
@property
|
|
332
|
+
def has_ended(self) -> bool:
|
|
333
|
+
return self._scope.has_ended
|
|
334
|
+
|
|
335
|
+
async def query(
|
|
336
|
+
self,
|
|
337
|
+
name: str,
|
|
338
|
+
query: str,
|
|
339
|
+
options: Optional[QueryOptions] = None,
|
|
340
|
+
) -> SearchResult:
|
|
341
|
+
"""
|
|
342
|
+
Run a tagged query against a loaded index.
|
|
343
|
+
|
|
344
|
+
Matches the semantics of ``moss.MossClient.query``: if
|
|
345
|
+
``options.embedding`` is provided we use it directly, otherwise
|
|
346
|
+
we embed the query text using the index's own model.
|
|
347
|
+
"""
|
|
348
|
+
top_k = getattr(options, "top_k", None) or 5
|
|
349
|
+
alpha = getattr(options, "alpha", None) or 0.8
|
|
350
|
+
embedding = getattr(options, "embedding", None)
|
|
351
|
+
filter = getattr(options, "filter", None)
|
|
352
|
+
|
|
353
|
+
if embedding is None:
|
|
354
|
+
return await asyncio.to_thread(
|
|
355
|
+
self._scope.query_text, name, query, top_k, alpha, filter
|
|
356
|
+
)
|
|
357
|
+
return await asyncio.to_thread(
|
|
358
|
+
self._scope.query,
|
|
359
|
+
name,
|
|
360
|
+
query,
|
|
361
|
+
list(embedding),
|
|
362
|
+
top_k,
|
|
363
|
+
alpha,
|
|
364
|
+
filter,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
async def query_multi_index(
|
|
368
|
+
self,
|
|
369
|
+
names: List[str],
|
|
370
|
+
query: str,
|
|
371
|
+
options: Optional[QueryOptions] = None,
|
|
372
|
+
) -> SearchResult:
|
|
373
|
+
"""
|
|
374
|
+
Search across multiple loaded indexes. ``alpha`` is ignored
|
|
375
|
+
(multi-index search is embedding-only - see the underlying
|
|
376
|
+
``moss.MossClient.query_multi_index`` docs for why).
|
|
377
|
+
"""
|
|
378
|
+
top_k = getattr(options, "top_k", None) or 10
|
|
379
|
+
embedding = getattr(options, "embedding", None)
|
|
380
|
+
filter = getattr(options, "filter", None)
|
|
381
|
+
|
|
382
|
+
if embedding is None:
|
|
383
|
+
return await asyncio.to_thread(
|
|
384
|
+
self._scope.query_multi_index_text, names, query, top_k, filter
|
|
385
|
+
)
|
|
386
|
+
return await asyncio.to_thread(
|
|
387
|
+
self._scope.query_multi_index,
|
|
388
|
+
names,
|
|
389
|
+
query,
|
|
390
|
+
list(embedding),
|
|
391
|
+
top_k,
|
|
392
|
+
filter,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
async def end(self) -> None:
|
|
396
|
+
"""
|
|
397
|
+
End the call scope explicitly. Idempotent.
|
|
398
|
+
|
|
399
|
+
Normally you don't need to call this - ``attach`` registers a
|
|
400
|
+
shutdown callback on the LiveKit ``JobContext`` that calls
|
|
401
|
+
``end`` for you when the room tears down. Call it manually only
|
|
402
|
+
if you need the scope closed before the room ends.
|
|
403
|
+
"""
|
|
404
|
+
await asyncio.to_thread(self._scope.end)
|
|
File without changes
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: moss-agent
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Moss runtime for LiveKit voice agents - a hot index cache shared across rooms and a one-line attach per call.
|
|
5
|
+
Author-email: "InferEdge Inc." <contact@usemoss.dev>
|
|
6
|
+
Project-URL: Homepage, https://github.com/usemoss/moss-samples
|
|
7
|
+
Project-URL: Repository, https://github.com/usemoss/moss-samples
|
|
8
|
+
Project-URL: Documentation, https://docs.usemoss.dev/
|
|
9
|
+
Keywords: livekit,voice-agent,semantic-search,moss,usemoss
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE.txt
|
|
22
|
+
Requires-Dist: typing-extensions>=4.0.0
|
|
23
|
+
Requires-Dist: inferedge-moss-core==0.12.0
|
|
24
|
+
Provides-Extra: livekit
|
|
25
|
+
Requires-Dist: livekit-agents>=0.10.0; extra == "livekit"
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=8.4.2; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-asyncio>=1.2.0; extra == "dev"
|
|
29
|
+
Requires-Dist: black>=25.9.0; extra == "dev"
|
|
30
|
+
Requires-Dist: isort>=7.0.0; extra == "dev"
|
|
31
|
+
Requires-Dist: mypy>=1.18.2; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# moss-agent
|
|
35
|
+
|
|
36
|
+
Moss runtime for LiveKit voice agents - a hot index cache shared across rooms and a one-line attach per call.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install moss-agent
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Use - voice (LiveKit)
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from livekit.agents import AgentServer, JobContext, JobProcess
|
|
48
|
+
from moss_agent import MossAgent
|
|
49
|
+
|
|
50
|
+
server = AgentServer()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def prewarm(proc: JobProcess) -> None:
|
|
54
|
+
agent = MossAgent(
|
|
55
|
+
project_id="...",
|
|
56
|
+
project_key="...",
|
|
57
|
+
)
|
|
58
|
+
import asyncio
|
|
59
|
+
asyncio.run(agent.load_indexes(["product_catalog", "faq", "policies"]))
|
|
60
|
+
proc.userdata["moss_agent"] = agent
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
server.setup_fnc = prewarm
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@server.rtc_session(agent_name="my-agent")
|
|
67
|
+
async def handle_visit(ctx: JobContext) -> None:
|
|
68
|
+
await ctx.connect()
|
|
69
|
+
|
|
70
|
+
agent: MossAgent = ctx.proc.userdata["moss_agent"]
|
|
71
|
+
call = agent.attach(ctx)
|
|
72
|
+
|
|
73
|
+
results = await call.query("product_catalog", user_question)
|
|
74
|
+
# ... your existing agent loop ...
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Use - text (HTTP / chat / non-LiveKit)
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from moss_agent import MossAgent
|
|
81
|
+
|
|
82
|
+
# Module level - reuse across requests
|
|
83
|
+
agent = MossAgent(project_id="...", project_key="...")
|
|
84
|
+
await agent.load_indexes(["product_catalog"])
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# In your HTTP / chat handler
|
|
88
|
+
async def handle_message(req):
|
|
89
|
+
results = await agent.query("product_catalog", req.user_message)
|
|
90
|
+
# ... feed results to your model ...
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## API surface
|
|
94
|
+
|
|
95
|
+
- `MossAgent(project_id, project_key)` - process-wide instance. Build once, share across every room (voice) or every request (text).
|
|
96
|
+
- **Voice:** `MossAgent.attach(ctx) -> MossCall` binds a Moss call scope to a LiveKit `JobContext`. Idempotent on `ctx.room.name`. `MossCall.query(name, query, options=None)` and `MossCall.query_multi_index(names, query, options=None)` are the call-scoped query surface.
|
|
97
|
+
- **Text:** `MossAgent.query(name, query, options=None)` and `MossAgent.query_multi_index(names, query, options=None)`.
|
|
98
|
+
- Full index CRUD (`create_index`, `add_docs`, `delete_docs`, `delete_index`, `list_indexes`, `get_index`, `get_docs`, `get_job_status`) and cache lifecycle (`load_index`, `load_indexes`, `unload_index`, `unload_indexes`) on `MossAgent`.
|
|
99
|
+
|
|
100
|
+
## Requirements
|
|
101
|
+
|
|
102
|
+
- Python 3.10+
|
|
103
|
+
- `inferedge-moss-core == 0.12.0`
|
|
104
|
+
- `livekit-agents >= 0.10.0` (only required if you call `attach()`; install via the `[livekit]` extra)
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
Proprietary. See `LICENSE.txt`.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE.txt
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/moss_agent/__init__.py
|
|
5
|
+
src/moss_agent/client.py
|
|
6
|
+
src/moss_agent/py.typed
|
|
7
|
+
src/moss_agent.egg-info/PKG-INFO
|
|
8
|
+
src/moss_agent.egg-info/SOURCES.txt
|
|
9
|
+
src/moss_agent.egg-info/dependency_links.txt
|
|
10
|
+
src/moss_agent.egg-info/requires.txt
|
|
11
|
+
src/moss_agent.egg-info/top_level.txt
|
|
12
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
moss_agent
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for the ``moss_agent`` Python wrapper layer.
|
|
3
|
+
|
|
4
|
+
Scope: behaviors implemented in Python - constructor argument forwarding,
|
|
5
|
+
LiveKit ``JobContext`` unpacking in ``attach``, query-method routing
|
|
6
|
+
between embedding and text variants, asyncio bridging via
|
|
7
|
+
``asyncio.to_thread``. Rust-side semantics (idempotency, lifecycle,
|
|
8
|
+
concurrency, text-mode delay floor) are covered by the moss_core test
|
|
9
|
+
suite.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import datetime
|
|
15
|
+
from types import SimpleNamespace
|
|
16
|
+
from unittest.mock import MagicMock, patch
|
|
17
|
+
|
|
18
|
+
import pytest
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------- Helpers ----------
|
|
22
|
+
|
|
23
|
+
def _make_fake_ctx(
|
|
24
|
+
*,
|
|
25
|
+
room_name: str = "rm_1",
|
|
26
|
+
job_id: str | None = "job_1",
|
|
27
|
+
creation_time: datetime.datetime | None = None,
|
|
28
|
+
):
|
|
29
|
+
"""Build a minimal stand-in for ``livekit.agents.JobContext``."""
|
|
30
|
+
if creation_time is None:
|
|
31
|
+
creation_time = datetime.datetime(
|
|
32
|
+
2024, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc
|
|
33
|
+
)
|
|
34
|
+
callbacks: list = []
|
|
35
|
+
|
|
36
|
+
ctx = MagicMock()
|
|
37
|
+
ctx.room.name = room_name
|
|
38
|
+
ctx.room.creation_time = creation_time
|
|
39
|
+
if job_id is None:
|
|
40
|
+
ctx.job = None
|
|
41
|
+
else:
|
|
42
|
+
ctx.job.id = job_id
|
|
43
|
+
ctx.add_shutdown_callback = callbacks.append
|
|
44
|
+
return ctx, callbacks
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------- MossAgent constructor ----------
|
|
48
|
+
|
|
49
|
+
@patch("moss_agent.client._Agent")
|
|
50
|
+
def test_constructor_forwards_credentials_only(
|
|
51
|
+
mock_agent_class: MagicMock,
|
|
52
|
+
) -> None:
|
|
53
|
+
from moss_agent import MossAgent
|
|
54
|
+
|
|
55
|
+
MossAgent("p", "k")
|
|
56
|
+
mock_agent_class.assert_called_once_with(
|
|
57
|
+
"p", "k", base_url=None, client_id=None
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@patch("moss_agent.client._Agent")
|
|
62
|
+
def test_constructor_forwards_base_url_and_client_id(
|
|
63
|
+
mock_agent_class: MagicMock,
|
|
64
|
+
) -> None:
|
|
65
|
+
from moss_agent import MossAgent
|
|
66
|
+
|
|
67
|
+
MossAgent("p", "k", base_url="http://x", client_id="cid")
|
|
68
|
+
mock_agent_class.assert_called_once_with(
|
|
69
|
+
"p", "k", base_url="http://x", client_id="cid"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@patch("moss_agent.client._Agent")
|
|
74
|
+
def test_client_id_property_reads_from_rust(mock_agent_class: MagicMock) -> None:
|
|
75
|
+
mock_agent_class.return_value.client_id.return_value = "cid-xyz"
|
|
76
|
+
|
|
77
|
+
from moss_agent import MossAgent
|
|
78
|
+
|
|
79
|
+
assert MossAgent("p", "k").client_id == "cid-xyz"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------- attach() ctx unpacking ----------
|
|
83
|
+
|
|
84
|
+
@patch("moss_agent.client._Agent")
|
|
85
|
+
def test_attach_converts_creation_time_to_unix_ms(
|
|
86
|
+
mock_agent_class: MagicMock,
|
|
87
|
+
) -> None:
|
|
88
|
+
mock_agent_class.return_value.start_call.return_value = MagicMock()
|
|
89
|
+
|
|
90
|
+
from moss_agent import MossAgent
|
|
91
|
+
|
|
92
|
+
creation = datetime.datetime(
|
|
93
|
+
2024, 6, 15, 12, 0, 0, tzinfo=datetime.timezone.utc
|
|
94
|
+
)
|
|
95
|
+
expected_ms = int(creation.timestamp() * 1000)
|
|
96
|
+
|
|
97
|
+
ctx, _ = _make_fake_ctx(creation_time=creation)
|
|
98
|
+
MossAgent("p", "k").attach(ctx)
|
|
99
|
+
|
|
100
|
+
mock_agent_class.return_value.start_call.assert_called_once_with(
|
|
101
|
+
"rm_1",
|
|
102
|
+
expected_ms,
|
|
103
|
+
livekit_session_id="job_1",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@patch("moss_agent.client._Agent")
|
|
108
|
+
def test_attach_handles_missing_job(mock_agent_class: MagicMock) -> None:
|
|
109
|
+
mock_agent_class.return_value.start_call.return_value = MagicMock()
|
|
110
|
+
|
|
111
|
+
from moss_agent import MossAgent
|
|
112
|
+
|
|
113
|
+
ctx, _ = _make_fake_ctx(job_id=None)
|
|
114
|
+
MossAgent("p", "k").attach(ctx)
|
|
115
|
+
|
|
116
|
+
_, kwargs = mock_agent_class.return_value.start_call.call_args
|
|
117
|
+
assert kwargs["livekit_session_id"] is None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@patch("moss_agent.client._Agent")
|
|
121
|
+
def test_attach_registers_shutdown_callback(
|
|
122
|
+
mock_agent_class: MagicMock,
|
|
123
|
+
) -> None:
|
|
124
|
+
mock_agent_class.return_value.start_call.return_value = MagicMock()
|
|
125
|
+
|
|
126
|
+
from moss_agent import MossAgent
|
|
127
|
+
|
|
128
|
+
ctx, callbacks = _make_fake_ctx()
|
|
129
|
+
MossAgent("p", "k").attach(ctx)
|
|
130
|
+
|
|
131
|
+
assert len(callbacks) == 1
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@pytest.mark.asyncio
|
|
135
|
+
@patch("moss_agent.client._Agent")
|
|
136
|
+
async def test_attach_shutdown_callback_invokes_scope_end(
|
|
137
|
+
mock_agent_class: MagicMock,
|
|
138
|
+
) -> None:
|
|
139
|
+
scope = MagicMock()
|
|
140
|
+
mock_agent_class.return_value.start_call.return_value = scope
|
|
141
|
+
|
|
142
|
+
from moss_agent import MossAgent
|
|
143
|
+
|
|
144
|
+
ctx, callbacks = _make_fake_ctx()
|
|
145
|
+
MossAgent("p", "k").attach(ctx)
|
|
146
|
+
|
|
147
|
+
await callbacks[0]("test reason")
|
|
148
|
+
scope.end.assert_called_once()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ---------- MossAgent text-mode query routing ----------
|
|
152
|
+
|
|
153
|
+
@pytest.mark.asyncio
|
|
154
|
+
@patch("moss_agent.client._Agent")
|
|
155
|
+
async def test_text_query_without_embedding_uses_query_text(
|
|
156
|
+
mock_agent_class: MagicMock,
|
|
157
|
+
) -> None:
|
|
158
|
+
inner = mock_agent_class.return_value
|
|
159
|
+
|
|
160
|
+
from moss_agent import MossAgent
|
|
161
|
+
|
|
162
|
+
await MossAgent("p", "k").query("kb1", "hello")
|
|
163
|
+
inner.query_text.assert_called_once_with("kb1", "hello", 5, 0.8, None)
|
|
164
|
+
inner.query.assert_not_called()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@pytest.mark.asyncio
|
|
168
|
+
@patch("moss_agent.client._Agent")
|
|
169
|
+
async def test_text_query_with_embedding_uses_embedding_path(
|
|
170
|
+
mock_agent_class: MagicMock,
|
|
171
|
+
) -> None:
|
|
172
|
+
inner = mock_agent_class.return_value
|
|
173
|
+
options = SimpleNamespace(
|
|
174
|
+
top_k=10, alpha=0.7, embedding=[0.1, 0.2, 0.3], filter=None
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
from moss_agent import MossAgent
|
|
178
|
+
|
|
179
|
+
await MossAgent("p", "k").query("kb1", "hello", options=options)
|
|
180
|
+
inner.query.assert_called_once_with(
|
|
181
|
+
"kb1", "hello", [0.1, 0.2, 0.3], 10, 0.7, None
|
|
182
|
+
)
|
|
183
|
+
inner.query_text.assert_not_called()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@pytest.mark.asyncio
|
|
187
|
+
@patch("moss_agent.client._Agent")
|
|
188
|
+
async def test_text_multi_index_without_embedding_uses_text_variant(
|
|
189
|
+
mock_agent_class: MagicMock,
|
|
190
|
+
) -> None:
|
|
191
|
+
inner = mock_agent_class.return_value
|
|
192
|
+
|
|
193
|
+
from moss_agent import MossAgent
|
|
194
|
+
|
|
195
|
+
await MossAgent("p", "k").query_multi_index(["kb1", "kb2"], "hello")
|
|
196
|
+
inner.query_multi_index_text.assert_called_once_with(
|
|
197
|
+
["kb1", "kb2"], "hello", 10, None
|
|
198
|
+
)
|
|
199
|
+
inner.query_multi_index.assert_not_called()
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@pytest.mark.asyncio
|
|
203
|
+
@patch("moss_agent.client._Agent")
|
|
204
|
+
async def test_text_multi_index_with_embedding_uses_embedding_variant(
|
|
205
|
+
mock_agent_class: MagicMock,
|
|
206
|
+
) -> None:
|
|
207
|
+
inner = mock_agent_class.return_value
|
|
208
|
+
options = SimpleNamespace(top_k=5, embedding=[0.1, 0.2], filter=None)
|
|
209
|
+
|
|
210
|
+
from moss_agent import MossAgent
|
|
211
|
+
|
|
212
|
+
await MossAgent("p", "k").query_multi_index(
|
|
213
|
+
["kb1", "kb2"], "hello", options=options
|
|
214
|
+
)
|
|
215
|
+
inner.query_multi_index.assert_called_once_with(
|
|
216
|
+
["kb1", "kb2"], "hello", [0.1, 0.2], 5, None
|
|
217
|
+
)
|
|
218
|
+
inner.query_multi_index_text.assert_not_called()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ---------- MossCall accessors ----------
|
|
222
|
+
|
|
223
|
+
def test_moss_call_passthrough_properties() -> None:
|
|
224
|
+
scope = MagicMock()
|
|
225
|
+
scope.call_id = "cid"
|
|
226
|
+
scope.livekit_room_id = "rm_1"
|
|
227
|
+
scope.livekit_session_id = "sess"
|
|
228
|
+
scope.start_at_unix_ms = 12345
|
|
229
|
+
scope.has_ended = False
|
|
230
|
+
|
|
231
|
+
from moss_agent import MossCall
|
|
232
|
+
|
|
233
|
+
call = MossCall(scope)
|
|
234
|
+
assert call.call_id == "cid"
|
|
235
|
+
assert call.livekit_room_id == "rm_1"
|
|
236
|
+
assert call.livekit_session_id == "sess"
|
|
237
|
+
assert call.start_at_unix_ms == 12345
|
|
238
|
+
assert call.has_ended is False
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ---------- MossCall.query routing (voice) ----------
|
|
242
|
+
|
|
243
|
+
@pytest.mark.asyncio
|
|
244
|
+
async def test_call_query_without_embedding_uses_query_text() -> None:
|
|
245
|
+
scope = MagicMock()
|
|
246
|
+
|
|
247
|
+
from moss_agent import MossCall
|
|
248
|
+
|
|
249
|
+
await MossCall(scope).query("kb1", "hello")
|
|
250
|
+
scope.query_text.assert_called_once_with("kb1", "hello", 5, 0.8, None)
|
|
251
|
+
scope.query.assert_not_called()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@pytest.mark.asyncio
|
|
255
|
+
async def test_call_query_with_embedding_uses_embedding_path() -> None:
|
|
256
|
+
scope = MagicMock()
|
|
257
|
+
options = SimpleNamespace(
|
|
258
|
+
top_k=10, alpha=0.7, embedding=[0.1, 0.2, 0.3], filter=None
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
from moss_agent import MossCall
|
|
262
|
+
|
|
263
|
+
await MossCall(scope).query("kb1", "hello", options)
|
|
264
|
+
scope.query.assert_called_once_with(
|
|
265
|
+
"kb1", "hello", [0.1, 0.2, 0.3], 10, 0.7, None
|
|
266
|
+
)
|
|
267
|
+
scope.query_text.assert_not_called()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@pytest.mark.asyncio
|
|
271
|
+
async def test_call_query_multi_index_without_embedding_uses_text_variant() -> None:
|
|
272
|
+
scope = MagicMock()
|
|
273
|
+
|
|
274
|
+
from moss_agent import MossCall
|
|
275
|
+
|
|
276
|
+
await MossCall(scope).query_multi_index(["kb1", "kb2"], "hello")
|
|
277
|
+
scope.query_multi_index_text.assert_called_once_with(
|
|
278
|
+
["kb1", "kb2"], "hello", 10, None
|
|
279
|
+
)
|
|
280
|
+
scope.query_multi_index.assert_not_called()
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@pytest.mark.asyncio
|
|
284
|
+
async def test_call_query_multi_index_with_embedding_uses_embedding_variant() -> None:
|
|
285
|
+
scope = MagicMock()
|
|
286
|
+
options = SimpleNamespace(top_k=5, embedding=[0.1, 0.2], filter=None)
|
|
287
|
+
|
|
288
|
+
from moss_agent import MossCall
|
|
289
|
+
|
|
290
|
+
await MossCall(scope).query_multi_index(["kb1", "kb2"], "hello", options)
|
|
291
|
+
scope.query_multi_index.assert_called_once_with(
|
|
292
|
+
["kb1", "kb2"], "hello", [0.1, 0.2], 5, None
|
|
293
|
+
)
|
|
294
|
+
scope.query_multi_index_text.assert_not_called()
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@pytest.mark.asyncio
|
|
298
|
+
async def test_call_end_invokes_scope_end() -> None:
|
|
299
|
+
scope = MagicMock()
|
|
300
|
+
|
|
301
|
+
from moss_agent import MossCall
|
|
302
|
+
|
|
303
|
+
await MossCall(scope).end()
|
|
304
|
+
scope.end.assert_called_once()
|