autochatlib 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.
autochat/__init__.py ADDED
@@ -0,0 +1,66 @@
1
+ from .chat import AutoChat
2
+ from .compression import (
3
+ AutoCompress,
4
+ CompressionStrategy,
5
+ KeepLatestN,
6
+ SummarizeAll,
7
+ SummarizeLatestN,
8
+ )
9
+ from .config import ChatConfig
10
+ from .exceptions import (
11
+ AutoChatError,
12
+ AutoChatToolError,
13
+ )
14
+ from .guidelines import ChatGuideline
15
+ from .retrieval import (
16
+ ChatRetriever,
17
+ RAGStrategy,
18
+ RetrievedDocument,
19
+ RetrievalConfig,
20
+ RetrievalInvocation,
21
+ RetrievalResult,
22
+ RetrieverFn,
23
+ RetrieverPostprocessor,
24
+ RetrieverPreprocessor,
25
+ SimpleRAGStrategy,
26
+ )
27
+ from .runtime import (
28
+ ChatRuntime,
29
+ )
30
+ from .tools import (
31
+ ChatTool,
32
+ ToolInvocation,
33
+ ToolPostprocessor,
34
+ ToolPreprocessor,
35
+ chat_tool,
36
+ )
37
+
38
+ __version__ = "0.1.0"
39
+ __all__ = [
40
+ "AutoChat",
41
+ "AutoCompress",
42
+ "ChatConfig",
43
+ "CompressionStrategy",
44
+ "KeepLatestN",
45
+ "SummarizeAll",
46
+ "SummarizeLatestN",
47
+ "AutoChatError",
48
+ "AutoChatToolError",
49
+ "ChatRuntime",
50
+ "ChatRetriever",
51
+ "RetrievalConfig",
52
+ "RetrievedDocument",
53
+ "RetrievalResult",
54
+ "RAGStrategy",
55
+ "SimpleRAGStrategy",
56
+ "RetrievalInvocation",
57
+ "RetrieverFn",
58
+ "RetrieverPostprocessor",
59
+ "RetrieverPreprocessor",
60
+ "ChatTool",
61
+ "ToolInvocation",
62
+ "ToolPostprocessor",
63
+ "ToolPreprocessor",
64
+ "chat_tool",
65
+ "ChatGuideline",
66
+ ]
autochat/chat.py ADDED
@@ -0,0 +1,116 @@
1
+ from typing import Any, AsyncIterator, Generic, Literal, Mapping, Sequence, TypeVar
2
+ from uuid import uuid4
3
+
4
+ from langchain_core.messages import HumanMessage
5
+ from langchain_core.runnables import RunnableConfig
6
+ from langgraph.checkpoint.base import BaseCheckpointSaver
7
+
8
+ from autochat.config import ChatConfig
9
+ from autochat.compression import AutoCompress
10
+ from autochat.graph.builder import build_chat_graph
11
+ from autochat.graph.runtime import with_runtime_config
12
+ from autochat.guidelines import ChatGuideline
13
+ from autochat.retrieval import ChatRetriever, RetrievalConfig
14
+ from autochat.runtime import ChatRuntime
15
+ from autochat.tools import ChatTool
16
+
17
+ TContext = TypeVar("TContext")
18
+
19
+
20
+ class AutoChat(Generic[TContext]):
21
+ def __init__(
22
+ self,
23
+ *,
24
+ config: ChatConfig,
25
+ tools: Sequence[ChatTool[TContext, Any, Any]] = (),
26
+ retrievers: Sequence[ChatRetriever[TContext]] = (),
27
+ retrieval: RetrievalConfig[TContext] | None = None,
28
+ persistence: BaseCheckpointSaver | None = None,
29
+ compression: AutoCompress[TContext] | None = None,
30
+ system_message: str | None = None,
31
+ guidelines: Sequence[ChatGuideline] = (),
32
+ ) -> None:
33
+ self.config = config
34
+ self.tools = tuple(tools)
35
+ self.retrievers = tuple(retrievers)
36
+ self.retrieval = retrieval or RetrievalConfig()
37
+ self.system_message = system_message
38
+ self.guidelines = tuple(guidelines)
39
+ self.persistence = persistence
40
+ self.compression = compression
41
+
42
+ self._graph = build_chat_graph(
43
+ config=config,
44
+ tools=self.tools,
45
+ retrievers=self.retrievers,
46
+ retrieval=self.retrieval,
47
+ compression=compression,
48
+ system_message=system_message,
49
+ guidelines=self.guidelines,
50
+ persistence=persistence,
51
+ )
52
+
53
+ def _runtime(
54
+ self,
55
+ *,
56
+ thread_id: str,
57
+ context: TContext,
58
+ run_id: str | None,
59
+ metadata: Mapping[str, Any] | None,
60
+ ) -> ChatRuntime[TContext]:
61
+ return ChatRuntime(
62
+ thread_id=thread_id,
63
+ context=context,
64
+ run_id=run_id or str(uuid4()),
65
+ metadata=metadata or {},
66
+ )
67
+
68
+ async def ainvoke(
69
+ self,
70
+ input: str,
71
+ *,
72
+ thread_id: str,
73
+ context: TContext,
74
+ run_id: str | None = None,
75
+ metadata: Mapping[str, Any] | None = None,
76
+ config: RunnableConfig | None = None,
77
+ ) -> Any:
78
+ runtime = self._runtime(
79
+ thread_id=thread_id,
80
+ context=context,
81
+ run_id=run_id,
82
+ metadata=metadata,
83
+ )
84
+
85
+ graph_config = with_runtime_config(config, runtime)
86
+
87
+ return await self._graph.ainvoke(
88
+ {"messages": [HumanMessage(content=input)]}, config=graph_config
89
+ )
90
+
91
+ async def astream_events(
92
+ self,
93
+ input: str,
94
+ *,
95
+ thread_id: str,
96
+ context: TContext,
97
+ run_id: str | None = None,
98
+ metadata: Mapping[str, Any] | None = None,
99
+ config: RunnableConfig | None = None,
100
+ version: Literal["v1", "v2"] = "v2",
101
+ ) -> AsyncIterator[Any]:
102
+ runtime = self._runtime(
103
+ thread_id=thread_id,
104
+ context=context,
105
+ run_id=run_id,
106
+ metadata=metadata,
107
+ )
108
+
109
+ graph_config = with_runtime_config(config, runtime)
110
+
111
+ async for event in self._graph.astream_events(
112
+ {"messages": [HumanMessage(content=input)]},
113
+ config=graph_config,
114
+ version=version,
115
+ ):
116
+ yield event
@@ -0,0 +1,11 @@
1
+ from .config import AutoCompress
2
+ from .strategies import KeepLatestN, SummarizeAll, SummarizeLatestN
3
+ from .types import CompressionStrategy
4
+
5
+ __all__ = [
6
+ "AutoCompress",
7
+ "CompressionStrategy",
8
+ "KeepLatestN",
9
+ "SummarizeAll",
10
+ "SummarizeLatestN",
11
+ ]
@@ -0,0 +1,86 @@
1
+ import warnings
2
+ from dataclasses import dataclass, field
3
+ from typing import Generic, TypeVar, cast
4
+
5
+ from langchain.chat_models import BaseChatModel
6
+ from langchain_core.messages import BaseMessage
7
+
8
+ from autochat.config import ChatConfig
9
+ from autochat.runtime import ChatRuntime
10
+
11
+ from .strategies import SummarizeAll
12
+ from .types import CompressionStrategy
13
+
14
+ TContext = TypeVar("TContext")
15
+
16
+
17
+ def _message_content_text(message: BaseMessage) -> str:
18
+ content = message.content
19
+ if isinstance(content, str):
20
+ return content
21
+ return str(content)
22
+
23
+
24
+ # TODO: Check if this approach is good
25
+ def _approximate_message_tokens(messages: list[BaseMessage]) -> int:
26
+ # Rough fallback used when the model cannot count tokens without extra deps.
27
+ return max(1, sum(len(_message_content_text(message)) for message in messages) // 4)
28
+
29
+
30
+ def _count_message_tokens(
31
+ model: BaseChatModel,
32
+ messages: list[BaseMessage],
33
+ ) -> int:
34
+ try:
35
+ with warnings.catch_warnings():
36
+ warnings.filterwarnings(
37
+ "ignore",
38
+ message="Using fallback GPT-2 tokenizer.*",
39
+ category=UserWarning,
40
+ )
41
+ return model.get_num_tokens_from_messages(messages)
42
+ except ImportError:
43
+ return _approximate_message_tokens(messages)
44
+
45
+
46
+ @dataclass(frozen=True, slots=True)
47
+ class AutoCompress(Generic[TContext]):
48
+ """Automatic chat-history compression policy."""
49
+
50
+ at: float = 0.6
51
+ strategy: CompressionStrategy[TContext] = field(
52
+ default_factory=lambda: cast(CompressionStrategy[TContext], SummarizeAll())
53
+ )
54
+ context_window: int | None = None
55
+ model: BaseChatModel | None = None
56
+
57
+ def __post_init__(self) -> None:
58
+ if not 0 < self.at <= 1:
59
+ raise ValueError(
60
+ "AutoCompress.at must be greater than 0 and less than or equal to 1."
61
+ )
62
+
63
+ async def acompress_if_needed(
64
+ self,
65
+ messages: list[BaseMessage],
66
+ *,
67
+ runtime: ChatRuntime[TContext],
68
+ config: ChatConfig,
69
+ ) -> list[BaseMessage] | None:
70
+ context_window = self.context_window or config.context_window
71
+ if context_window is None:
72
+ raise ValueError(
73
+ "AutoCompress requires ChatConfig.context_window or "
74
+ "AutoCompress.context_window."
75
+ )
76
+
77
+ model = self.model or config.model
78
+ used_tokens = _count_message_tokens(model, messages)
79
+ if used_tokens < context_window * self.at:
80
+ return None
81
+
82
+ return await self.strategy.acompress(
83
+ messages,
84
+ runtime=runtime,
85
+ model=model,
86
+ )
@@ -0,0 +1,186 @@
1
+ from collections.abc import Sequence
2
+ from dataclasses import dataclass
3
+ from typing import Generic, TypeVar
4
+
5
+ from langchain.chat_models import BaseChatModel
6
+ from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
7
+ from langgraph.graph.message import REMOVE_ALL_MESSAGES, RemoveMessage
8
+
9
+ from autochat.runtime import ChatRuntime
10
+
11
+ TContext = TypeVar("TContext")
12
+
13
+ _SUMMARY_METADATA = {
14
+ "autochat": {
15
+ "kind": "compression_summary",
16
+ }
17
+ }
18
+
19
+
20
+ def _summary_message(summary: str) -> SystemMessage:
21
+ return SystemMessage(
22
+ content=f"Previous conversation summary:\n{summary}",
23
+ additional_kwargs=_SUMMARY_METADATA,
24
+ )
25
+
26
+
27
+ def _replace_messages(
28
+ messages: Sequence[BaseMessage],
29
+ ) -> list[BaseMessage]:
30
+ return [
31
+ RemoveMessage(id=REMOVE_ALL_MESSAGES),
32
+ *messages,
33
+ ]
34
+
35
+
36
+ def _replace_with_summary_between(
37
+ messages_before: Sequence[BaseMessage],
38
+ summary: str,
39
+ messages_after: Sequence[BaseMessage],
40
+ ) -> list[BaseMessage]:
41
+ return _replace_messages(
42
+ [
43
+ *messages_before,
44
+ _summary_message(summary),
45
+ *messages_after,
46
+ ]
47
+ )
48
+
49
+
50
+ def _replace_with_latest(
51
+ messages_to_keep: Sequence[BaseMessage],
52
+ ) -> list[BaseMessage]:
53
+ return _replace_messages(messages_to_keep)
54
+
55
+
56
+ def _split_preserved_tail(
57
+ messages: Sequence[BaseMessage],
58
+ preserve_latest: int,
59
+ ) -> tuple[list[BaseMessage], list[BaseMessage]]:
60
+ message_list = list(messages)
61
+ if preserve_latest <= 0:
62
+ return message_list, []
63
+ return message_list[:-preserve_latest], message_list[-preserve_latest:]
64
+
65
+
66
+ def _message_text(message: BaseMessage) -> str:
67
+ content = message.content
68
+ if isinstance(content, str):
69
+ return content
70
+ return str(content)
71
+
72
+
73
+ def _format_messages_for_summary(messages: Sequence[BaseMessage]) -> str:
74
+ lines: list[str] = []
75
+ for message in messages:
76
+ role = message.type
77
+ name = getattr(message, "name", None)
78
+ label = f"{role}:{name}" if name else role
79
+ lines.append(f"{label}: {_message_text(message)}")
80
+ return "\n".join(lines)
81
+
82
+
83
+ async def _summarize_messages(
84
+ messages: Sequence[BaseMessage],
85
+ *,
86
+ model: BaseChatModel,
87
+ ) -> str:
88
+ prompt = (
89
+ "Summarize this conversation for future continuation. "
90
+ "Preserve user goals, decisions, constraints, tool results, and unresolved tasks. "
91
+ "Do not add facts that are not present.\n\n"
92
+ f"Conversation:\n{_format_messages_for_summary(messages)}"
93
+ )
94
+ response = await model.ainvoke([HumanMessage(content=prompt)])
95
+ content = response.content
96
+ if isinstance(content, str):
97
+ return content
98
+ return str(content)
99
+
100
+
101
+ @dataclass(frozen=True, slots=True)
102
+ class KeepLatestN(Generic[TContext]):
103
+ """Drop older history and keep only the latest N messages."""
104
+
105
+ n: int
106
+
107
+ def __post_init__(self) -> None:
108
+ if self.n < 1:
109
+ raise ValueError("KeepLatestN.n must be at least 1.")
110
+
111
+ async def acompress(
112
+ self,
113
+ messages: Sequence[BaseMessage],
114
+ *,
115
+ runtime: ChatRuntime[TContext],
116
+ model: BaseChatModel,
117
+ ) -> list[BaseMessage]:
118
+ del runtime, model
119
+ return _replace_with_latest(list(messages)[-self.n :])
120
+
121
+
122
+ @dataclass(frozen=True, slots=True)
123
+ class SummarizeAll(Generic[TContext]):
124
+ """Summarize history into one summary and preserve the active latest messages."""
125
+
126
+ preserve_latest: int = 1
127
+
128
+ def __post_init__(self) -> None:
129
+ if self.preserve_latest < 0:
130
+ raise ValueError("SummarizeAll.preserve_latest cannot be negative.")
131
+
132
+ async def acompress(
133
+ self,
134
+ messages: Sequence[BaseMessage],
135
+ *,
136
+ runtime: ChatRuntime[TContext],
137
+ model: BaseChatModel,
138
+ ) -> list[BaseMessage]:
139
+ del runtime
140
+ messages_to_summarize, messages_to_keep = _split_preserved_tail(
141
+ messages,
142
+ self.preserve_latest,
143
+ )
144
+ if not messages_to_summarize:
145
+ return _replace_with_latest(messages_to_keep)
146
+
147
+ summary = await _summarize_messages(messages_to_summarize, model=model)
148
+ return _replace_with_summary_between([], summary, messages_to_keep)
149
+
150
+
151
+ @dataclass(frozen=True, slots=True)
152
+ class SummarizeLatestN(Generic[TContext]):
153
+ """Summarize the latest N history messages and preserve the active latest messages."""
154
+
155
+ n: int
156
+ preserve_latest: int = 1
157
+
158
+ def __post_init__(self) -> None:
159
+ if self.n < 1:
160
+ raise ValueError("SummarizeLatestN.n must be at least 1.")
161
+ if self.preserve_latest < 0:
162
+ raise ValueError("SummarizeLatestN.preserve_latest cannot be negative.")
163
+
164
+ async def acompress(
165
+ self,
166
+ messages: Sequence[BaseMessage],
167
+ *,
168
+ runtime: ChatRuntime[TContext],
169
+ model: BaseChatModel,
170
+ ) -> list[BaseMessage]:
171
+ del runtime
172
+ history_messages, preserved_messages = _split_preserved_tail(
173
+ messages,
174
+ self.preserve_latest,
175
+ )
176
+ messages_to_summarize = history_messages[-self.n :]
177
+ messages_to_keep = history_messages[: -self.n]
178
+ if not messages_to_summarize:
179
+ return _replace_with_latest(preserved_messages)
180
+
181
+ summary = await _summarize_messages(messages_to_summarize, model=model)
182
+ return _replace_with_summary_between(
183
+ messages_to_keep,
184
+ summary,
185
+ preserved_messages,
186
+ )
@@ -0,0 +1,21 @@
1
+ from collections.abc import Sequence
2
+ from typing import Generic, Protocol, TypeVar
3
+
4
+ from langchain.chat_models import BaseChatModel
5
+ from langchain_core.messages import BaseMessage
6
+
7
+ from autochat.runtime import ChatRuntime
8
+
9
+ TContext = TypeVar("TContext")
10
+
11
+
12
+ class CompressionStrategy(Protocol[TContext]):
13
+ """Strategy used by AutoCompress to compact chat history."""
14
+
15
+ async def acompress(
16
+ self,
17
+ messages: Sequence[BaseMessage],
18
+ *,
19
+ runtime: ChatRuntime[TContext],
20
+ model: BaseChatModel,
21
+ ) -> list[BaseMessage]: ...
autochat/config.py ADDED
@@ -0,0 +1,11 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any
3
+
4
+ from langchain.chat_models import BaseChatModel
5
+
6
+
7
+ @dataclass(frozen=True, slots=True)
8
+ class ChatConfig:
9
+ model: BaseChatModel
10
+ model_kwargs: dict[str, Any] | None = None
11
+ context_window: int | None = None
@@ -0,0 +1,3 @@
1
+ from .errors import AutoChatError, AutoChatToolError
2
+
3
+ __all__ = ["AutoChatError", "AutoChatToolError"]
@@ -0,0 +1,6 @@
1
+ class AutoChatError(Exception):
2
+ """Base exception for AutoChat."""
3
+
4
+
5
+ class AutoChatToolError(AutoChatError):
6
+ """Base exception for AutoChat tool execution errors."""
@@ -0,0 +1,4 @@
1
+ from .builder import build_chat_graph
2
+ from .state import ChatGraphState
3
+
4
+ __all__ = ["build_chat_graph", "ChatGraphState"]
@@ -0,0 +1,151 @@
1
+ from collections.abc import Sequence
2
+ from typing import Any
3
+
4
+ from langchain_core.messages import AIMessage, BaseMessage, SystemMessage
5
+ from langchain_core.runnables import RunnableConfig
6
+ from langgraph.checkpoint.base import BaseCheckpointSaver
7
+ from langgraph.graph import END, START, StateGraph
8
+ from langgraph.graph.state import CompiledStateGraph
9
+
10
+ from autochat.compression import AutoCompress
11
+ from autochat.config import ChatConfig
12
+ from autochat.graph.state import ChatGraphState, ChatGraphUpdate
13
+ from autochat.graph.tools import run_tool_calls
14
+ from autochat.graph.runtime import get_runtime
15
+ from autochat.guidelines import ChatGuideline
16
+ from autochat.retrieval import (
17
+ ChatRetriever,
18
+ RetrievalConfig,
19
+ )
20
+ from autochat.tools import ChatTool
21
+
22
+
23
+ def validate_callable_names(
24
+ tools: Sequence[ChatTool[Any, Any, Any]],
25
+ retrievers: Sequence[ChatRetriever[Any]],
26
+ ) -> None:
27
+ """Validate that tool and retriever names are unique."""
28
+ names = [tool.name for tool in tools] + [retriever.name for retriever in retrievers]
29
+ duplicates = {name for name in names if names.count(name) > 1}
30
+
31
+ if duplicates:
32
+ duplicate_names = ", ".join(sorted(duplicates))
33
+ raise ValueError(f"Duplicate tool/retriever names: {duplicate_names}")
34
+
35
+
36
+ def build_system_messages(
37
+ system_message: str | None,
38
+ guidelines: Sequence[ChatGuideline],
39
+ ) -> list[SystemMessage]:
40
+ messages: list[SystemMessage] = []
41
+
42
+ if system_message:
43
+ messages.append(SystemMessage(content=system_message))
44
+
45
+ if guidelines:
46
+ guideline_text = "\n".join(f"- {guideline.content}" for guideline in guidelines)
47
+ messages.append(SystemMessage(content=f"Guidelines:\n{guideline_text}"))
48
+
49
+ return messages
50
+
51
+
52
+ def should_continue(state: ChatGraphState) -> str:
53
+ last_message = state["messages"][-1]
54
+
55
+ if isinstance(last_message, AIMessage) and last_message.tool_calls:
56
+ return "tools"
57
+
58
+ return END
59
+
60
+
61
+ def build_chat_graph(
62
+ *,
63
+ config: ChatConfig,
64
+ tools: Sequence[ChatTool[Any, Any, Any]],
65
+ retrievers: Sequence[ChatRetriever[Any]],
66
+ retrieval: RetrievalConfig[Any],
67
+ compression: AutoCompress[Any] | None,
68
+ system_message: str | None,
69
+ guidelines: Sequence[ChatGuideline],
70
+ persistence: BaseCheckpointSaver | None,
71
+ ) -> CompiledStateGraph:
72
+ graph = StateGraph(ChatGraphState)
73
+ system_messages = build_system_messages(system_message, guidelines)
74
+
75
+ chat_config = config
76
+ model = chat_config.model
77
+ if tools or retrievers:
78
+ validate_callable_names(tools, retrievers)
79
+
80
+ model = model.bind_tools(
81
+ [
82
+ *(tool.model_tool() for tool in tools),
83
+ *(retriever.model_tool() for retriever in retrievers),
84
+ ]
85
+ )
86
+
87
+ async def call_model(
88
+ state: ChatGraphState,
89
+ config: RunnableConfig,
90
+ ) -> ChatGraphUpdate:
91
+ messages: list[BaseMessage] = [
92
+ *system_messages,
93
+ *state["messages"],
94
+ ]
95
+
96
+ response = await model.ainvoke(
97
+ messages, config=config, **(chat_config.model_kwargs or {})
98
+ )
99
+
100
+ response_messages: list[BaseMessage] = [response]
101
+ return {"messages": response_messages}
102
+
103
+ async def call_tools(
104
+ state: ChatGraphState,
105
+ config: RunnableConfig,
106
+ ) -> ChatGraphUpdate:
107
+ last_message = state["messages"][-1]
108
+
109
+ if not isinstance(last_message, AIMessage):
110
+ return {"messages": []}
111
+
112
+ tool_messages = await run_tool_calls(
113
+ last_message,
114
+ tools,
115
+ retrievers,
116
+ config,
117
+ )
118
+ messages: list[BaseMessage] = list(tool_messages)
119
+ return {"messages": messages}
120
+
121
+ async def compress_messages(
122
+ state: ChatGraphState,
123
+ config: RunnableConfig,
124
+ ) -> ChatGraphUpdate:
125
+ if compression is None:
126
+ return {"messages": []}
127
+
128
+ messages = await compression.acompress_if_needed(
129
+ list(state["messages"]),
130
+ runtime=get_runtime(config),
131
+ config=chat_config,
132
+ )
133
+ if messages is None:
134
+ return {"messages": []}
135
+
136
+ return {"messages": messages}
137
+
138
+ graph.add_node("agent", call_model)
139
+ graph.add_node("tools", call_tools)
140
+
141
+ if compression:
142
+ graph.add_node("compression", compress_messages)
143
+ graph.add_edge(START, "compression")
144
+ graph.add_edge("compression", "agent")
145
+ else:
146
+ graph.add_edge(START, "agent")
147
+
148
+ graph.add_conditional_edges("agent", should_continue)
149
+ graph.add_edge("tools", "agent")
150
+
151
+ return graph.compile(checkpointer=persistence)