threadlessai 0.1.0__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.
- threadlessai/__init__.py +37 -0
- threadlessai/_async_client.py +146 -0
- threadlessai/_client.py +152 -0
- threadlessai/_core.py +217 -0
- threadlessai/_note_tool.py +147 -0
- threadlessai/py.typed +0 -0
- threadlessai-0.1.0.dist-info/METADATA +105 -0
- threadlessai-0.1.0.dist-info/RECORD +10 -0
- threadlessai-0.1.0.dist-info/WHEEL +4 -0
- threadlessai-0.1.0.dist-info/licenses/LICENSE +8 -0
threadlessai/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Threadless AI: memory for agents. Decorate every model call, report every reply."""
|
|
2
|
+
|
|
3
|
+
from ._async_client import AsyncSurface, AsyncThreadless
|
|
4
|
+
from ._client import Surface, Threadless
|
|
5
|
+
from ._core import ErrorContext, ThreadlessError, trim_tail
|
|
6
|
+
from ._note_tool import (
|
|
7
|
+
NOTE_RESULT,
|
|
8
|
+
NOTE_TOOL_DESCRIPTION,
|
|
9
|
+
NOTE_TOOL_NAME,
|
|
10
|
+
NOTE_TOOL_PARAMETERS,
|
|
11
|
+
Format,
|
|
12
|
+
NoteFact,
|
|
13
|
+
is_note_call,
|
|
14
|
+
note_tool_definition,
|
|
15
|
+
read_note_facts,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"AsyncSurface",
|
|
22
|
+
"AsyncThreadless",
|
|
23
|
+
"ErrorContext",
|
|
24
|
+
"Format",
|
|
25
|
+
"NOTE_RESULT",
|
|
26
|
+
"NOTE_TOOL_DESCRIPTION",
|
|
27
|
+
"NOTE_TOOL_NAME",
|
|
28
|
+
"NOTE_TOOL_PARAMETERS",
|
|
29
|
+
"NoteFact",
|
|
30
|
+
"Surface",
|
|
31
|
+
"Threadless",
|
|
32
|
+
"ThreadlessError",
|
|
33
|
+
"is_note_call",
|
|
34
|
+
"note_tool_definition",
|
|
35
|
+
"read_note_facts",
|
|
36
|
+
"trim_tail",
|
|
37
|
+
]
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Any, Dict, List, Optional, Set
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from ._core import (
|
|
9
|
+
DEFAULT_TAIL_TOKENS,
|
|
10
|
+
DEFAULT_TIMEOUT,
|
|
11
|
+
REPORT_WAIT,
|
|
12
|
+
BaseClient,
|
|
13
|
+
OnError,
|
|
14
|
+
Raw,
|
|
15
|
+
falls_back,
|
|
16
|
+
items_of,
|
|
17
|
+
plain,
|
|
18
|
+
quote_id,
|
|
19
|
+
)
|
|
20
|
+
from ._note_tool import Format, note_tool_definition
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AsyncMemories:
|
|
24
|
+
def __init__(self, client: "AsyncThreadless") -> None:
|
|
25
|
+
self._client = client
|
|
26
|
+
|
|
27
|
+
async def create(self, metadata: Optional[Dict[str, str]] = None) -> Raw:
|
|
28
|
+
body: Raw = {} if metadata is None else {"metadata": metadata}
|
|
29
|
+
return await self._client._call("POST", "/memories", body)
|
|
30
|
+
|
|
31
|
+
async def get(self, memory_id: str) -> Raw:
|
|
32
|
+
return await self._client._call("GET", f"/memories/{quote_id(memory_id)}")
|
|
33
|
+
|
|
34
|
+
async def delete(self, memory_id: str) -> None:
|
|
35
|
+
await self._client._call("DELETE", f"/memories/{quote_id(memory_id)}")
|
|
36
|
+
self._client._forget_tail(memory_id)
|
|
37
|
+
|
|
38
|
+
async def export(self, memory_id: str) -> Raw:
|
|
39
|
+
return await self._client._call("GET", f"/memories/{quote_id(memory_id)}/export")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AsyncSurface:
|
|
43
|
+
"""One OpenAI API: `client.chat` (Chat Completions) or `client.responses`."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, client: "AsyncThreadless", format: Format) -> None:
|
|
46
|
+
self._client = client
|
|
47
|
+
self._format = format
|
|
48
|
+
#: The note tool for this API: add it to your tools once.
|
|
49
|
+
self.note_tool: Raw = note_tool_definition(format)
|
|
50
|
+
|
|
51
|
+
async def decorate(self, memory_id: str, new_messages: List[Any], request: Raw) -> Raw:
|
|
52
|
+
"""Before every model call: hand over what is new since the last call and get
|
|
53
|
+
back the request to submit. When we cannot be reached it never fails the
|
|
54
|
+
call: the request is built from the local tail of recent turns instead."""
|
|
55
|
+
client = self._client
|
|
56
|
+
pending = client._reports.get(memory_id)
|
|
57
|
+
if pending is not None:
|
|
58
|
+
await asyncio.wait({pending}, timeout=REPORT_WAIT)
|
|
59
|
+
messages: List[Raw] = [plain(item) for item in new_messages]
|
|
60
|
+
try:
|
|
61
|
+
out = await client._call(
|
|
62
|
+
"POST",
|
|
63
|
+
f"/memories/{quote_id(memory_id)}/decorate",
|
|
64
|
+
{"format": self._format, "messages": messages, "request": request},
|
|
65
|
+
)
|
|
66
|
+
decorated = out.get("request") if isinstance(out, dict) else None
|
|
67
|
+
client._remember_tail(memory_id, items_of(self._format, decorated))
|
|
68
|
+
return decorated # type: ignore[return-value]
|
|
69
|
+
except Exception as error:
|
|
70
|
+
if not falls_back(error):
|
|
71
|
+
raise
|
|
72
|
+
client._notify(error, "decorate", memory_id)
|
|
73
|
+
return client._fallback(self._format, memory_id, messages, request)
|
|
74
|
+
|
|
75
|
+
def report(self, memory_id: str, reply: Any) -> None:
|
|
76
|
+
"""After the model replies: hand over the reply. Schedules a task on the
|
|
77
|
+
running loop and never raises."""
|
|
78
|
+
client = self._client
|
|
79
|
+
try:
|
|
80
|
+
body = {"format": self._format, "reply": plain(reply)}
|
|
81
|
+
task = asyncio.get_running_loop().create_task(client._send_report(memory_id, body))
|
|
82
|
+
except Exception as error:
|
|
83
|
+
client._notify_quietly(error, memory_id)
|
|
84
|
+
return
|
|
85
|
+
client._reports[memory_id] = task
|
|
86
|
+
client._tasks.add(task)
|
|
87
|
+
task.add_done_callback(lambda done: client._report_done(memory_id, done))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class AsyncThreadless(BaseClient):
|
|
91
|
+
"""The Threadless AI client for asyncio."""
|
|
92
|
+
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
api_key: Optional[str] = None,
|
|
96
|
+
base_url: Optional[str] = None,
|
|
97
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
98
|
+
tail_tokens: int = DEFAULT_TAIL_TOKENS,
|
|
99
|
+
on_error: Optional[OnError] = None,
|
|
100
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
101
|
+
) -> None:
|
|
102
|
+
super().__init__(api_key, base_url, timeout, tail_tokens, on_error)
|
|
103
|
+
self._owns_http = http_client is None
|
|
104
|
+
self._http = http_client if http_client is not None else httpx.AsyncClient()
|
|
105
|
+
#: Per memory: a report still in flight.
|
|
106
|
+
self._reports: Dict[str, "asyncio.Task[None]"] = {}
|
|
107
|
+
#: Every report task, held so none is garbage-collected mid-flight.
|
|
108
|
+
self._tasks: Set["asyncio.Task[None]"] = set()
|
|
109
|
+
self.memories = AsyncMemories(self)
|
|
110
|
+
self.chat = AsyncSurface(self, "chat.completions")
|
|
111
|
+
self.responses = AsyncSurface(self, "responses")
|
|
112
|
+
|
|
113
|
+
async def _call(self, method: str, path: str, body: Any = None) -> Any:
|
|
114
|
+
"""One request to the runtime API. Raises ThreadlessError on an error response."""
|
|
115
|
+
response = await self._http.request(**self._request(method, path, body))
|
|
116
|
+
return self._parse(response)
|
|
117
|
+
|
|
118
|
+
async def _send_report(self, memory_id: str, body: Raw) -> None:
|
|
119
|
+
try:
|
|
120
|
+
await self._call("POST", f"/memories/{quote_id(memory_id)}/report", body)
|
|
121
|
+
except Exception as error:
|
|
122
|
+
self._notify_quietly(error, memory_id)
|
|
123
|
+
|
|
124
|
+
def _notify_quietly(self, error: BaseException, memory_id: str) -> None:
|
|
125
|
+
try:
|
|
126
|
+
self._notify(error, "report", memory_id)
|
|
127
|
+
except Exception:
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
def _report_done(self, memory_id: str, task: "asyncio.Task[None]") -> None:
|
|
131
|
+
self._tasks.discard(task)
|
|
132
|
+
if self._reports.get(memory_id) is task:
|
|
133
|
+
del self._reports[memory_id]
|
|
134
|
+
|
|
135
|
+
async def aclose(self) -> None:
|
|
136
|
+
"""Waits for reports still in flight, then closes the HTTP client."""
|
|
137
|
+
if self._tasks:
|
|
138
|
+
await asyncio.gather(*self._tasks, return_exceptions=True)
|
|
139
|
+
if self._owns_http:
|
|
140
|
+
await self._http.aclose()
|
|
141
|
+
|
|
142
|
+
async def __aenter__(self) -> "AsyncThreadless":
|
|
143
|
+
return self
|
|
144
|
+
|
|
145
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
146
|
+
await self.aclose()
|
threadlessai/_client.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import concurrent.futures
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from ._core import (
|
|
9
|
+
DEFAULT_TAIL_TOKENS,
|
|
10
|
+
DEFAULT_TIMEOUT,
|
|
11
|
+
REPORT_WAIT,
|
|
12
|
+
BaseClient,
|
|
13
|
+
OnError,
|
|
14
|
+
Raw,
|
|
15
|
+
falls_back,
|
|
16
|
+
items_of,
|
|
17
|
+
plain,
|
|
18
|
+
quote_id,
|
|
19
|
+
)
|
|
20
|
+
from ._note_tool import Format, note_tool_definition
|
|
21
|
+
|
|
22
|
+
REPORT_WORKERS = 4
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Memories:
|
|
26
|
+
def __init__(self, client: "Threadless") -> None:
|
|
27
|
+
self._client = client
|
|
28
|
+
|
|
29
|
+
def create(self, metadata: Optional[Dict[str, str]] = None) -> Raw:
|
|
30
|
+
body: Raw = {} if metadata is None else {"metadata": metadata}
|
|
31
|
+
return self._client._call("POST", "/memories", body)
|
|
32
|
+
|
|
33
|
+
def get(self, memory_id: str) -> Raw:
|
|
34
|
+
return self._client._call("GET", f"/memories/{quote_id(memory_id)}")
|
|
35
|
+
|
|
36
|
+
def delete(self, memory_id: str) -> None:
|
|
37
|
+
self._client._call("DELETE", f"/memories/{quote_id(memory_id)}")
|
|
38
|
+
self._client._forget_tail(memory_id)
|
|
39
|
+
|
|
40
|
+
def export(self, memory_id: str) -> Raw:
|
|
41
|
+
return self._client._call("GET", f"/memories/{quote_id(memory_id)}/export")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Surface:
|
|
45
|
+
"""One OpenAI API: `client.chat` (Chat Completions) or `client.responses`."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, client: "Threadless", format: Format) -> None:
|
|
48
|
+
self._client = client
|
|
49
|
+
self._format = format
|
|
50
|
+
#: The note tool for this API: add it to your tools once.
|
|
51
|
+
self.note_tool: Raw = note_tool_definition(format)
|
|
52
|
+
|
|
53
|
+
def decorate(self, memory_id: str, new_messages: List[Any], request: Raw) -> Raw:
|
|
54
|
+
"""Before every model call: hand over what is new since the last call and get
|
|
55
|
+
back the request to submit. When we cannot be reached it never fails the
|
|
56
|
+
call: the request is built from the local tail of recent turns instead."""
|
|
57
|
+
client = self._client
|
|
58
|
+
client._wait_for_report(memory_id)
|
|
59
|
+
messages: List[Raw] = [plain(item) for item in new_messages]
|
|
60
|
+
try:
|
|
61
|
+
out = client._call(
|
|
62
|
+
"POST",
|
|
63
|
+
f"/memories/{quote_id(memory_id)}/decorate",
|
|
64
|
+
{"format": self._format, "messages": messages, "request": request},
|
|
65
|
+
)
|
|
66
|
+
decorated = out.get("request") if isinstance(out, dict) else None
|
|
67
|
+
client._remember_tail(memory_id, items_of(self._format, decorated))
|
|
68
|
+
return decorated # type: ignore[return-value]
|
|
69
|
+
except Exception as error:
|
|
70
|
+
if not falls_back(error):
|
|
71
|
+
raise
|
|
72
|
+
client._notify(error, "decorate", memory_id)
|
|
73
|
+
return client._fallback(self._format, memory_id, messages, request)
|
|
74
|
+
|
|
75
|
+
def report(self, memory_id: str, reply: Any) -> None:
|
|
76
|
+
"""After the model replies: hand over the reply. Runs in the background and
|
|
77
|
+
never raises."""
|
|
78
|
+
client = self._client
|
|
79
|
+
try:
|
|
80
|
+
body = {"format": self._format, "reply": plain(reply)}
|
|
81
|
+
future = client._executor.submit(client._send_report, memory_id, body)
|
|
82
|
+
except Exception as error:
|
|
83
|
+
client._notify_quietly(error, memory_id)
|
|
84
|
+
return
|
|
85
|
+
with client._lock:
|
|
86
|
+
client._reports[memory_id] = future
|
|
87
|
+
future.add_done_callback(lambda done: client._report_done(memory_id, done))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Threadless(BaseClient):
|
|
91
|
+
"""The Threadless AI client. Reports run on a small background thread pool."""
|
|
92
|
+
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
api_key: Optional[str] = None,
|
|
96
|
+
base_url: Optional[str] = None,
|
|
97
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
98
|
+
tail_tokens: int = DEFAULT_TAIL_TOKENS,
|
|
99
|
+
on_error: Optional[OnError] = None,
|
|
100
|
+
http_client: Optional[httpx.Client] = None,
|
|
101
|
+
) -> None:
|
|
102
|
+
super().__init__(api_key, base_url, timeout, tail_tokens, on_error)
|
|
103
|
+
self._owns_http = http_client is None
|
|
104
|
+
self._http = http_client if http_client is not None else httpx.Client()
|
|
105
|
+
self._executor = concurrent.futures.ThreadPoolExecutor(
|
|
106
|
+
max_workers=REPORT_WORKERS, thread_name_prefix="threadless-report"
|
|
107
|
+
)
|
|
108
|
+
#: Per memory: a report still in flight.
|
|
109
|
+
self._reports: Dict[str, "concurrent.futures.Future[None]"] = {}
|
|
110
|
+
self.memories = Memories(self)
|
|
111
|
+
self.chat = Surface(self, "chat.completions")
|
|
112
|
+
self.responses = Surface(self, "responses")
|
|
113
|
+
|
|
114
|
+
def _call(self, method: str, path: str, body: Any = None) -> Any:
|
|
115
|
+
"""One request to the runtime API. Raises ThreadlessError on an error response."""
|
|
116
|
+
response = self._http.request(**self._request(method, path, body))
|
|
117
|
+
return self._parse(response)
|
|
118
|
+
|
|
119
|
+
def _send_report(self, memory_id: str, body: Raw) -> None:
|
|
120
|
+
try:
|
|
121
|
+
self._call("POST", f"/memories/{quote_id(memory_id)}/report", body)
|
|
122
|
+
except Exception as error:
|
|
123
|
+
self._notify_quietly(error, memory_id)
|
|
124
|
+
|
|
125
|
+
def _notify_quietly(self, error: BaseException, memory_id: str) -> None:
|
|
126
|
+
try:
|
|
127
|
+
self._notify(error, "report", memory_id)
|
|
128
|
+
except Exception:
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
def _report_done(self, memory_id: str, future: "concurrent.futures.Future[None]") -> None:
|
|
132
|
+
with self._lock:
|
|
133
|
+
if self._reports.get(memory_id) is future:
|
|
134
|
+
del self._reports[memory_id]
|
|
135
|
+
|
|
136
|
+
def _wait_for_report(self, memory_id: str) -> None:
|
|
137
|
+
with self._lock:
|
|
138
|
+
pending = self._reports.get(memory_id)
|
|
139
|
+
if pending is not None:
|
|
140
|
+
concurrent.futures.wait([pending], timeout=REPORT_WAIT)
|
|
141
|
+
|
|
142
|
+
def close(self) -> None:
|
|
143
|
+
"""Waits for reports still in flight, then closes the HTTP client."""
|
|
144
|
+
self._executor.shutdown(wait=True)
|
|
145
|
+
if self._owns_http:
|
|
146
|
+
self._http.close()
|
|
147
|
+
|
|
148
|
+
def __enter__(self) -> "Threadless":
|
|
149
|
+
return self
|
|
150
|
+
|
|
151
|
+
def __exit__(self, *exc: Any) -> None:
|
|
152
|
+
self.close()
|
threadlessai/_core.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""What the sync and async clients share: errors, the local tail, the HTTP shape."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
import threading
|
|
9
|
+
from collections import OrderedDict
|
|
10
|
+
from typing import Any, Callable, Dict, List, Optional, TypedDict
|
|
11
|
+
from urllib.parse import quote
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from ._note_tool import Format
|
|
16
|
+
|
|
17
|
+
Raw = Dict[str, Any]
|
|
18
|
+
|
|
19
|
+
DEFAULT_BASE_URL = "https://api.threadlessai.com/v1"
|
|
20
|
+
DEFAULT_TIMEOUT = 15.0
|
|
21
|
+
DEFAULT_TAIL_TOKENS = 60_000
|
|
22
|
+
#: A report still in flight holds the memory's next decorate this long at most (seconds).
|
|
23
|
+
REPORT_WAIT = 2.0
|
|
24
|
+
TAILS_KEPT = 200
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ErrorContext(TypedDict):
|
|
28
|
+
operation: str # "decorate" | "report"
|
|
29
|
+
memory_id: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
OnError = Callable[[BaseException, ErrorContext], None]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ThreadlessError(Exception):
|
|
36
|
+
"""An error response from the Threadless API."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, status: int, code: str, message: str, hint: Optional[str] = None) -> None:
|
|
39
|
+
super().__init__(message)
|
|
40
|
+
self.status = status
|
|
41
|
+
self.code = code
|
|
42
|
+
self.message = message
|
|
43
|
+
self.hint = hint
|
|
44
|
+
|
|
45
|
+
def __repr__(self) -> str:
|
|
46
|
+
return (
|
|
47
|
+
f"ThreadlessError(status={self.status!r}, code={self.code!r}, "
|
|
48
|
+
f"message={self.message!r}, hint={self.hint!r})"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _json_length(value: Any) -> int:
|
|
53
|
+
"""The length of JSON.stringify(value) in UTF-16 code units, as JavaScript counts it."""
|
|
54
|
+
text = json.dumps(value, separators=(",", ":"), ensure_ascii=False, default=str)
|
|
55
|
+
return len(text.encode("utf-16-le")) // 2
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def estimate_tokens(item: Any) -> int:
|
|
59
|
+
return math.ceil(_json_length(item if item is not None else "") / 4) + 4
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _get(item: Any, key: str) -> Any:
|
|
63
|
+
return item.get(key) if isinstance(item, dict) else None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def is_instruction(item: Any) -> bool:
|
|
67
|
+
return _get(item, "role") in ("system", "developer") and _get(item, "type") in (None, "message")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def is_user_message(item: Any) -> bool:
|
|
71
|
+
return _get(item, "role") == "user" and _get(item, "type") in (None, "message")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def trim_tail(items: List[Raw], budget: int) -> List[Raw]:
|
|
75
|
+
"""The local tail: the instructions, then the newest history that fits, cut at
|
|
76
|
+
a user message so a tool call is never separated from its output."""
|
|
77
|
+
instructions = [item for item in items if is_instruction(item)]
|
|
78
|
+
history = [item for item in items if not is_instruction(item)]
|
|
79
|
+
used = sum(estimate_tokens(item) for item in instructions)
|
|
80
|
+
start = len(history)
|
|
81
|
+
for index in range(len(history) - 1, -1, -1):
|
|
82
|
+
used += estimate_tokens(history[index])
|
|
83
|
+
if used > budget:
|
|
84
|
+
break
|
|
85
|
+
if is_user_message(history[index]):
|
|
86
|
+
start = index
|
|
87
|
+
if start == len(history) and history:
|
|
88
|
+
# Not even one turn fits: keep the newest user message onward.
|
|
89
|
+
users = [index for index, item in enumerate(history) if is_user_message(item)]
|
|
90
|
+
start = users[-1] if users else len(history) - 1
|
|
91
|
+
return instructions + history[start:]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _or(value: Any, default: Any) -> Any:
|
|
95
|
+
"""JavaScript's `??`."""
|
|
96
|
+
return default if value is None else value
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def quote_id(memory_id: str) -> str:
|
|
100
|
+
"""encodeURIComponent."""
|
|
101
|
+
return quote(memory_id, safe="!~*'()")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def items_of(format: Format, request: Any) -> List[Raw]:
|
|
105
|
+
if request is None:
|
|
106
|
+
raise TypeError("decorate returned no request")
|
|
107
|
+
items = _get(request, "input" if format == "responses" else "messages")
|
|
108
|
+
return items if isinstance(items, list) else []
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def plain(value: Any, **dump: Any) -> Any:
|
|
112
|
+
"""An openai SDK object as plain JSON data, with the API's own field names
|
|
113
|
+
(`async`, not the Python `async_`) and no empty fields; anything else as it is."""
|
|
114
|
+
model_dump = getattr(value, "model_dump", None)
|
|
115
|
+
if callable(model_dump):
|
|
116
|
+
return model_dump(mode="json", by_alias=True, exclude_none=True, **dump)
|
|
117
|
+
return value
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def falls_back(error: BaseException) -> bool:
|
|
121
|
+
"""Whether decorate goes ahead on the local tail rather than raising.
|
|
122
|
+
A request we sent wrong is ours to see, not a reason to go on without memory."""
|
|
123
|
+
if isinstance(error, ThreadlessError):
|
|
124
|
+
return error.status >= 500 or error.status in (429, 408)
|
|
125
|
+
return isinstance(error, Exception)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class BaseClient:
|
|
129
|
+
def __init__(
|
|
130
|
+
self,
|
|
131
|
+
api_key: Optional[str],
|
|
132
|
+
base_url: Optional[str],
|
|
133
|
+
timeout: float,
|
|
134
|
+
tail_tokens: int,
|
|
135
|
+
on_error: Optional[OnError],
|
|
136
|
+
) -> None:
|
|
137
|
+
key = api_key if api_key is not None else os.environ.get("THREADLESS_API_KEY")
|
|
138
|
+
if not key:
|
|
139
|
+
raise ValueError("Threadless: pass api_key or set THREADLESS_API_KEY.")
|
|
140
|
+
self._api_key = key
|
|
141
|
+
url = base_url if base_url is not None else os.environ.get("THREADLESS_BASE_URL")
|
|
142
|
+
url = url if url is not None else DEFAULT_BASE_URL
|
|
143
|
+
self.base_url = url[:-1] if url.endswith("/") else url
|
|
144
|
+
self.timeout = timeout
|
|
145
|
+
self.tail_tokens = tail_tokens
|
|
146
|
+
self._on_error = on_error
|
|
147
|
+
#: Per memory: the last request's items, for when we cannot be reached.
|
|
148
|
+
self.tails: "OrderedDict[str, List[Raw]]" = OrderedDict()
|
|
149
|
+
self._lock = threading.Lock()
|
|
150
|
+
|
|
151
|
+
def _request(self, method: str, path: str, body: Any) -> Dict[str, Any]:
|
|
152
|
+
headers = {"authorization": f"Bearer {self._api_key}"}
|
|
153
|
+
kwargs: Dict[str, Any] = {
|
|
154
|
+
"method": method,
|
|
155
|
+
"url": f"{self.base_url}{path}",
|
|
156
|
+
"headers": headers,
|
|
157
|
+
"timeout": self.timeout,
|
|
158
|
+
}
|
|
159
|
+
if body is not None:
|
|
160
|
+
headers["content-type"] = "application/json"
|
|
161
|
+
kwargs["content"] = json.dumps(body, separators=(",", ":")).encode("utf-8")
|
|
162
|
+
return kwargs
|
|
163
|
+
|
|
164
|
+
@staticmethod
|
|
165
|
+
def _parse(response: httpx.Response) -> Any:
|
|
166
|
+
if response.status_code == 204:
|
|
167
|
+
return None
|
|
168
|
+
text = response.text
|
|
169
|
+
data = json.loads(text) if text else {}
|
|
170
|
+
if not response.is_success:
|
|
171
|
+
error = data.get("error") if isinstance(data, dict) else None
|
|
172
|
+
error = error if isinstance(error, dict) else {}
|
|
173
|
+
raise ThreadlessError(
|
|
174
|
+
response.status_code,
|
|
175
|
+
_or(error.get("code"), "internal"),
|
|
176
|
+
_or(error.get("message"), f"HTTP {response.status_code}"),
|
|
177
|
+
error.get("hint"),
|
|
178
|
+
)
|
|
179
|
+
return data
|
|
180
|
+
|
|
181
|
+
def _notify(self, error: BaseException, operation: str, memory_id: str) -> None:
|
|
182
|
+
if self._on_error is not None:
|
|
183
|
+
self._on_error(error, {"operation": operation, "memory_id": memory_id})
|
|
184
|
+
|
|
185
|
+
def _remember_tail(self, memory_id: str, items: List[Raw]) -> None:
|
|
186
|
+
trimmed = trim_tail(items, self.tail_tokens)
|
|
187
|
+
with self._lock:
|
|
188
|
+
self.tails.pop(memory_id, None)
|
|
189
|
+
self.tails[memory_id] = trimmed
|
|
190
|
+
while len(self.tails) > TAILS_KEPT:
|
|
191
|
+
self.tails.popitem(last=False)
|
|
192
|
+
|
|
193
|
+
def _forget_tail(self, memory_id: str) -> None:
|
|
194
|
+
with self._lock:
|
|
195
|
+
self.tails.pop(memory_id, None)
|
|
196
|
+
|
|
197
|
+
def _fallback(
|
|
198
|
+
self, format: Format, memory_id: str, new_messages: List[Raw], request: Raw
|
|
199
|
+
) -> Raw:
|
|
200
|
+
"""The request built from the local tail of recent turns."""
|
|
201
|
+
with self._lock:
|
|
202
|
+
tail = list(self.tails.get(memory_id, []))
|
|
203
|
+
instructions = [item for item in new_messages if is_instruction(item)]
|
|
204
|
+
items = trim_tail(
|
|
205
|
+
(instructions or [item for item in tail if is_instruction(item)])
|
|
206
|
+
+ [item for item in tail if not is_instruction(item)]
|
|
207
|
+
+ [item for item in new_messages if not is_instruction(item)],
|
|
208
|
+
self.tail_tokens,
|
|
209
|
+
)
|
|
210
|
+
self._remember_tail(memory_id, items)
|
|
211
|
+
rest = {
|
|
212
|
+
key: value
|
|
213
|
+
for key, value in request.items()
|
|
214
|
+
if key not in ("messages", "input", "previous_response_id", "store")
|
|
215
|
+
}
|
|
216
|
+
rest["input" if format == "responses" else "messages"] = items
|
|
217
|
+
return rest
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""The note tool: the customer registers it like any tool. Nothing runs; the
|
|
2
|
+
call is the note. When part of the history is leaving live context the
|
|
3
|
+
request asks the model for its facts, and the model answers with one call.
|
|
4
|
+
The facts reach us in the reply, so the handler only returns NOTE_RESULT.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any, Dict, List, Literal, Optional, TypedDict
|
|
12
|
+
|
|
13
|
+
Format = Literal["chat.completions", "responses"]
|
|
14
|
+
|
|
15
|
+
NOTE_TOOL_NAME = "memory_note"
|
|
16
|
+
|
|
17
|
+
NOTE_RESULT = "noted"
|
|
18
|
+
|
|
19
|
+
NOTE_TOOL_DESCRIPTION = (
|
|
20
|
+
"Writes facts into this conversation's long-term memory. Nothing runs: the call itself is "
|
|
21
|
+
"the note. When the system asks for facts from history that is leaving live context, answer "
|
|
22
|
+
"with one call holding every fact: one entry per fact, a knowledge-graph triple readable on "
|
|
23
|
+
"its own, identifiers and the source's own terms verbatim. Once those turns leave, these "
|
|
24
|
+
"facts are what stays in view of them. When they hold nothing needed later, one entry with "
|
|
25
|
+
"an empty note."
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
NOTE_TOOL_PARAMETERS: Dict[str, Any] = {
|
|
29
|
+
"type": "object",
|
|
30
|
+
"properties": {
|
|
31
|
+
"notes": {
|
|
32
|
+
"type": "array",
|
|
33
|
+
"description": "Every fact being written, one entry each.",
|
|
34
|
+
"items": {
|
|
35
|
+
"type": "object",
|
|
36
|
+
"properties": {
|
|
37
|
+
"note": {
|
|
38
|
+
"type": "string",
|
|
39
|
+
"description": (
|
|
40
|
+
"One fact, as a sentence readable on its own. Empty when the history "
|
|
41
|
+
"leaving holds nothing needed later."
|
|
42
|
+
),
|
|
43
|
+
},
|
|
44
|
+
"subject": {
|
|
45
|
+
"type": "string",
|
|
46
|
+
"description": (
|
|
47
|
+
"The triple's subject entity, resolved to its canonical name (entity "
|
|
48
|
+
"resolution): the name earlier facts already use, else the most "
|
|
49
|
+
"specific name in the conversation."
|
|
50
|
+
),
|
|
51
|
+
},
|
|
52
|
+
"object": {
|
|
53
|
+
"type": "string",
|
|
54
|
+
"description": (
|
|
55
|
+
"The triple's object entity, resolved to its canonical name (entity "
|
|
56
|
+
"resolution). The fact becomes the edge between subject and object "
|
|
57
|
+
"in a knowledge graph."
|
|
58
|
+
),
|
|
59
|
+
},
|
|
60
|
+
"milestone": {
|
|
61
|
+
"type": "boolean",
|
|
62
|
+
"description": (
|
|
63
|
+
"True when the fact says where the work stands (a milestone reached, "
|
|
64
|
+
"a point of alignment, what is next or blocked)."
|
|
65
|
+
),
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
"required": ["note", "subject", "object", "milestone"],
|
|
69
|
+
"additionalProperties": False,
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
"required": ["notes"],
|
|
74
|
+
"additionalProperties": False,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def note_tool_definition(format: Format) -> Dict[str, Any]:
|
|
79
|
+
"""The note tool as a tool definition for the given API."""
|
|
80
|
+
if format == "responses":
|
|
81
|
+
return {
|
|
82
|
+
"type": "function",
|
|
83
|
+
"name": NOTE_TOOL_NAME,
|
|
84
|
+
"description": NOTE_TOOL_DESCRIPTION,
|
|
85
|
+
"parameters": NOTE_TOOL_PARAMETERS,
|
|
86
|
+
"strict": True,
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
"type": "function",
|
|
90
|
+
"function": {
|
|
91
|
+
"name": NOTE_TOOL_NAME,
|
|
92
|
+
"description": NOTE_TOOL_DESCRIPTION,
|
|
93
|
+
"parameters": NOTE_TOOL_PARAMETERS,
|
|
94
|
+
"strict": True,
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class NoteFact(TypedDict):
|
|
100
|
+
text: str
|
|
101
|
+
# Subject then object, as the model resolved them.
|
|
102
|
+
entities: List[str]
|
|
103
|
+
milestone: bool
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
_WHITESPACE = re.compile(r"\s+")
|
|
107
|
+
_BULLET = re.compile(r"^(?:[-*•]|[0-9]+[.)])\s+")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def read_note_facts(args: Any) -> Optional[List[NoteFact]]:
|
|
111
|
+
"""The facts one note call carries. Malformed entries are left out; None when it
|
|
112
|
+
carries no notes list."""
|
|
113
|
+
parsed = args
|
|
114
|
+
if isinstance(parsed, str):
|
|
115
|
+
try:
|
|
116
|
+
parsed = json.loads(parsed or "{}")
|
|
117
|
+
except ValueError:
|
|
118
|
+
return None
|
|
119
|
+
notes = parsed.get("notes") if isinstance(parsed, dict) else None
|
|
120
|
+
if not isinstance(notes, list):
|
|
121
|
+
return None
|
|
122
|
+
facts: List[NoteFact] = []
|
|
123
|
+
for entry in notes:
|
|
124
|
+
if not isinstance(entry, dict):
|
|
125
|
+
continue
|
|
126
|
+
note = entry.get("note")
|
|
127
|
+
if not isinstance(note, str):
|
|
128
|
+
continue
|
|
129
|
+
# The fact's two entities, subject then object; a note of several lines is several
|
|
130
|
+
# facts that share them.
|
|
131
|
+
cleaned = (
|
|
132
|
+
_WHITESPACE.sub(" ", entity).strip()
|
|
133
|
+
for entity in (entry.get("subject"), entry.get("object"))
|
|
134
|
+
if isinstance(entity, str)
|
|
135
|
+
)
|
|
136
|
+
entities: List[str] = list(dict.fromkeys(entity for entity in cleaned if entity))
|
|
137
|
+
milestone = entry.get("milestone") is True
|
|
138
|
+
for line in note.split("\n"):
|
|
139
|
+
text = _WHITESPACE.sub(" ", _BULLET.sub("", line.strip())).strip()
|
|
140
|
+
if text:
|
|
141
|
+
facts.append({"text": text, "entities": list(entities), "milestone": milestone})
|
|
142
|
+
return facts
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def is_note_call(name: Optional[str]) -> bool:
|
|
146
|
+
"""Whether a tool call is to the note tool. Answer it with NOTE_RESULT."""
|
|
147
|
+
return name == NOTE_TOOL_NAME
|
threadlessai/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: threadlessai
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Threadless AI: memory for agents. Decorate every model call, report every reply.
|
|
5
|
+
Project-URL: Homepage, https://threadlessai.com
|
|
6
|
+
License: Copyright (c) 2026 Threadless AI. All rights reserved.
|
|
7
|
+
|
|
8
|
+
This software is licensed, not sold. You may install and use it only to call
|
|
9
|
+
the Threadless AI service (threadlessai.com) under its Terms of Service. You
|
|
10
|
+
may not copy, modify, distribute, sublicense or reverse engineer it, or use it
|
|
11
|
+
with any other service, except as those Terms allow.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Keywords: agents,context,llm,long-term memory,memory,openai
|
|
16
|
+
Classifier: License :: Other/Proprietary License
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Requires-Dist: httpx>=0.23
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# threadlessai
|
|
26
|
+
|
|
27
|
+
Threadless AI: memory for agents. Decorate every model call, report every reply.
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
pip install threadlessai
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Set `THREADLESS_API_KEY` (a `tl_live_…` or `tl_test_…` project key), or pass `api_key=`.
|
|
34
|
+
|
|
35
|
+
## Chat Completions
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from openai import OpenAI
|
|
39
|
+
from threadlessai import NOTE_RESULT, Threadless, is_note_call
|
|
40
|
+
|
|
41
|
+
openai = OpenAI()
|
|
42
|
+
threadless = Threadless()
|
|
43
|
+
memory = threadless.memories.create() # one per conversation
|
|
44
|
+
|
|
45
|
+
new_messages = [
|
|
46
|
+
{"role": "system", "content": "You are a helpful assistant."},
|
|
47
|
+
{"role": "user", "content": "Hi, I'm planning a launch."},
|
|
48
|
+
]
|
|
49
|
+
while True:
|
|
50
|
+
request = threadless.chat.decorate(
|
|
51
|
+
memory["id"],
|
|
52
|
+
new_messages,
|
|
53
|
+
{"model": "gpt-5", "tools": [threadless.chat.note_tool]},
|
|
54
|
+
)
|
|
55
|
+
completion = openai.chat.completions.create(**request)
|
|
56
|
+
threadless.chat.report(memory["id"], completion) # background, never raises
|
|
57
|
+
|
|
58
|
+
message = completion.choices[0].message
|
|
59
|
+
calls = message.tool_calls or []
|
|
60
|
+
if not calls:
|
|
61
|
+
print(message.content)
|
|
62
|
+
break
|
|
63
|
+
# Hand over only what is new since the last decorate: the reply and the tool results.
|
|
64
|
+
new_messages = [message]
|
|
65
|
+
for call in calls:
|
|
66
|
+
output = NOTE_RESULT if is_note_call(call.function.name) else run_tool(call)
|
|
67
|
+
new_messages.append({"role": "tool", "tool_call_id": call.id, "content": output})
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Responses
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from openai import OpenAI
|
|
74
|
+
from threadlessai import NOTE_RESULT, Threadless, is_note_call
|
|
75
|
+
|
|
76
|
+
openai = OpenAI()
|
|
77
|
+
threadless = Threadless()
|
|
78
|
+
memory = threadless.memories.create()
|
|
79
|
+
|
|
80
|
+
new_items = [{"role": "user", "content": "Hi, I'm planning a launch."}]
|
|
81
|
+
while True:
|
|
82
|
+
request = threadless.responses.decorate(
|
|
83
|
+
memory["id"],
|
|
84
|
+
new_items,
|
|
85
|
+
{"model": "gpt-5", "tools": [threadless.responses.note_tool]},
|
|
86
|
+
)
|
|
87
|
+
response = openai.responses.create(**request)
|
|
88
|
+
threadless.responses.report(memory["id"], response)
|
|
89
|
+
|
|
90
|
+
calls = [item for item in response.output if item.type == "function_call"]
|
|
91
|
+
if not calls:
|
|
92
|
+
print(response.output_text)
|
|
93
|
+
break
|
|
94
|
+
new_items = [item.model_dump(mode="json", exclude_none=True) for item in response.output]
|
|
95
|
+
for call in calls:
|
|
96
|
+
output = NOTE_RESULT if is_note_call(call.name) else run_tool(call)
|
|
97
|
+
new_items.append({"type": "function_call_output", "call_id": call.call_id, "output": output})
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Notes
|
|
101
|
+
|
|
102
|
+
- **Decorate never fails your model call** on a network error, a timeout, a 5xx, 429 or 408: it builds the request from a local tail of recent turns and tells `on_error`. Any other 4xx raises `ThreadlessError` (`status`, `code`, `message`, `hint`).
|
|
103
|
+
- **Report runs in the background** and never raises. The next decorate for the same memory waits up to 2 seconds for it. Call `threadless.close()` (or use `with Threadless() as threadless:`) to wait for reports still in flight.
|
|
104
|
+
- **Async:** `AsyncThreadless` has the same surface; `await client.chat.decorate(...)`, and `client.chat.report(...)` schedules a task. Close it with `await client.aclose()`.
|
|
105
|
+
- `client.memories.get(id)`, `.delete(id)` and `.export(id)` manage a memory.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
threadlessai/__init__.py,sha256=21khSD-coOI7C592EqcBDzF7RLBrKaRin8j6UxxZH44,828
|
|
2
|
+
threadlessai/_async_client.py,sha256=51KhU1bfPLES4SZpxlOaBn9rbyivP6hO_08N2gV2Sbw,5785
|
|
3
|
+
threadlessai/_client.py,sha256=bgmraNCelih7GGybrPz-r1KwSwFQ1R1f7RGl56X6QPw,5779
|
|
4
|
+
threadlessai/_core.py,sha256=wwctIt1i-E8MlwzyGmzC6spXCmqSYf55O0z6uIi7OY0,7956
|
|
5
|
+
threadlessai/_note_tool.py,sha256=vSZ_NcUYPMRWyZJfu28oC4mJBhpu_2TaHjAZqSYFmgc,5491
|
|
6
|
+
threadlessai/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
threadlessai-0.1.0.dist-info/METADATA,sha256=pJQ34myWXUJPTrKi5SAPMlBgz4pE2dOoHwPm36nB8oI,4142
|
|
8
|
+
threadlessai-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
9
|
+
threadlessai-0.1.0.dist-info/licenses/LICENSE,sha256=y1rHnMSFQypd0yWpLipwVYXYc1n7eFJ-bbbdQ3mitg8,407
|
|
10
|
+
threadlessai-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Copyright (c) 2026 Threadless AI. All rights reserved.
|
|
2
|
+
|
|
3
|
+
This software is licensed, not sold. You may install and use it only to call
|
|
4
|
+
the Threadless AI service (threadlessai.com) under its Terms of Service. You
|
|
5
|
+
may not copy, modify, distribute, sublicense or reverse engineer it, or use it
|
|
6
|
+
with any other service, except as those Terms allow.
|
|
7
|
+
|
|
8
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
|