iil-chat-agent 0.1.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.
- iil_chat_agent-0.1.0/.gitignore +50 -0
- iil_chat_agent-0.1.0/PKG-INFO +54 -0
- iil_chat_agent-0.1.0/README.md +35 -0
- iil_chat_agent-0.1.0/pyproject.toml +44 -0
- iil_chat_agent-0.1.0/src/chat_agent/__init__.py +46 -0
- iil_chat_agent-0.1.0/src/chat_agent/agent.py +243 -0
- iil_chat_agent-0.1.0/src/chat_agent/composite.py +85 -0
- iil_chat_agent-0.1.0/src/chat_agent/models.py +124 -0
- iil_chat_agent-0.1.0/src/chat_agent/registry.py +73 -0
- iil_chat_agent-0.1.0/src/chat_agent/session.py +95 -0
- iil_chat_agent-0.1.0/src/chat_agent/toolkit.py +73 -0
- iil_chat_agent-0.1.0/tests/__init__.py +0 -0
- iil_chat_agent-0.1.0/tests/test_agent.py +533 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*\.class
|
|
5
|
+
.venv/
|
|
6
|
+
venv/
|
|
7
|
+
*.egg-info/
|
|
8
|
+
dist/
|
|
9
|
+
build/
|
|
10
|
+
# Django
|
|
11
|
+
*.log
|
|
12
|
+
db.sqlite3
|
|
13
|
+
media/
|
|
14
|
+
staticfiles/
|
|
15
|
+
# Environment
|
|
16
|
+
.env
|
|
17
|
+
.env.local
|
|
18
|
+
.env.prod
|
|
19
|
+
|
|
20
|
+
# Secrets (ADR-045) — NEVER commit plaintext
|
|
21
|
+
secrets.env
|
|
22
|
+
!secrets.enc.env
|
|
23
|
+
# IDE
|
|
24
|
+
.idea/
|
|
25
|
+
.vscode/
|
|
26
|
+
*.swp
|
|
27
|
+
# OS
|
|
28
|
+
.DS_Store
|
|
29
|
+
Thumbs.db
|
|
30
|
+
# Large files
|
|
31
|
+
*.sqlite3
|
|
32
|
+
*.log
|
|
33
|
+
output_batch/
|
|
34
|
+
app_backup/
|
|
35
|
+
docs_legacy/
|
|
36
|
+
# Sphinx build output
|
|
37
|
+
docs/_build/
|
|
38
|
+
*:Zone.Identifier
|
|
39
|
+
# Local Windsurf/MCP config — machine-specific, never commit
|
|
40
|
+
.windsurf/mcp_config.json
|
|
41
|
+
# Local infra plaintext secrets — NEVER commit
|
|
42
|
+
infra/*.env
|
|
43
|
+
infra/*.key
|
|
44
|
+
# Validate-ports CI draft (not yet integrated)
|
|
45
|
+
.github/workflows/validate-ports.yml
|
|
46
|
+
# Markdownlint local config override
|
|
47
|
+
.markdownlint.json
|
|
48
|
+
# Concept review drafts (local working copies, not for VCS)
|
|
49
|
+
docs/concepts/REVIEW-*
|
|
50
|
+
env_loader.py
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: iil-chat-agent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Domain-agnostic chat agent with Tool-Use loop
|
|
5
|
+
Author: Achim Dehnert
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: pydantic>=2.0
|
|
9
|
+
Provides-Extra: creative
|
|
10
|
+
Requires-Dist: creative-services>=0.1.0; extra == 'creative'
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
15
|
+
Requires-Dist: ruff>=0.1; extra == 'dev'
|
|
16
|
+
Provides-Extra: redis
|
|
17
|
+
Requires-Dist: redis>=5.0; extra == 'redis'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# chat-agent
|
|
21
|
+
|
|
22
|
+
Domain-agnostic Chat Agent with Tool-Use loop — platform package per ADR-034 §3.
|
|
23
|
+
|
|
24
|
+
## Core Components
|
|
25
|
+
|
|
26
|
+
- **ChatAgent**: Core Tool-Use loop (LLM → tool calls → execute → LLM)
|
|
27
|
+
- **DomainToolkit**: ABC for app-specific tool collections
|
|
28
|
+
- **SessionBackend**: Protocol for pluggable session storage (InMemory, Redis)
|
|
29
|
+
- **ToolkitRegistry**: Global registry for toolkit discovery
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install -e ".[dev]"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from chat_agent import ChatAgent, InMemorySessionBackend
|
|
41
|
+
|
|
42
|
+
agent = ChatAgent(
|
|
43
|
+
toolkit=MyToolkit(),
|
|
44
|
+
completion=my_llm_client,
|
|
45
|
+
session_backend=InMemorySessionBackend(),
|
|
46
|
+
system_prompt="You are a helpful assistant.",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
response = await agent.chat(
|
|
50
|
+
session_id="user-123",
|
|
51
|
+
user_message="How many rooms are on floor 2?",
|
|
52
|
+
tenant_id="tenant-abc",
|
|
53
|
+
)
|
|
54
|
+
```
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# chat-agent
|
|
2
|
+
|
|
3
|
+
Domain-agnostic Chat Agent with Tool-Use loop — platform package per ADR-034 §3.
|
|
4
|
+
|
|
5
|
+
## Core Components
|
|
6
|
+
|
|
7
|
+
- **ChatAgent**: Core Tool-Use loop (LLM → tool calls → execute → LLM)
|
|
8
|
+
- **DomainToolkit**: ABC for app-specific tool collections
|
|
9
|
+
- **SessionBackend**: Protocol for pluggable session storage (InMemory, Redis)
|
|
10
|
+
- **ToolkitRegistry**: Global registry for toolkit discovery
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install -e ".[dev]"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from chat_agent import ChatAgent, InMemorySessionBackend
|
|
22
|
+
|
|
23
|
+
agent = ChatAgent(
|
|
24
|
+
toolkit=MyToolkit(),
|
|
25
|
+
completion=my_llm_client,
|
|
26
|
+
session_backend=InMemorySessionBackend(),
|
|
27
|
+
system_prompt="You are a helpful assistant.",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
response = await agent.chat(
|
|
31
|
+
session_id="user-123",
|
|
32
|
+
user_message="How many rooms are on floor 2?",
|
|
33
|
+
tenant_id="tenant-abc",
|
|
34
|
+
)
|
|
35
|
+
```
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "iil-chat-agent"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Domain-agnostic chat agent with Tool-Use loop"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [{name = "Achim Dehnert"}]
|
|
13
|
+
|
|
14
|
+
dependencies = [
|
|
15
|
+
"pydantic>=2.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
creative = ["creative-services>=0.1.0"]
|
|
20
|
+
redis = ["redis>=5.0"]
|
|
21
|
+
dev = [
|
|
22
|
+
"pytest>=8.0",
|
|
23
|
+
"pytest-asyncio>=0.23",
|
|
24
|
+
"pytest-cov>=4.0",
|
|
25
|
+
"ruff>=0.1",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
packages = ["src/chat_agent"]
|
|
30
|
+
|
|
31
|
+
[tool.pytest.ini_options]
|
|
32
|
+
testpaths = ["tests"]
|
|
33
|
+
asyncio_mode = "strict"
|
|
34
|
+
|
|
35
|
+
[tool.ruff]
|
|
36
|
+
line-length = 100
|
|
37
|
+
target-version = "py311"
|
|
38
|
+
|
|
39
|
+
[tool.ruff.lint]
|
|
40
|
+
select = ["E", "F", "I", "W"]
|
|
41
|
+
ignore = ["E501"]
|
|
42
|
+
|
|
43
|
+
[tool.ruff.lint.isort]
|
|
44
|
+
known-first-party = ["chat_agent"]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""chat-agent — Domain-agnostic Chat Agent with Tool-Use loop.
|
|
2
|
+
|
|
3
|
+
Platform package per ADR-034 §3. Provides:
|
|
4
|
+
- ChatAgent: Core Tool-Use loop (LLM → tool calls → execute → LLM)
|
|
5
|
+
- DomainToolkit: ABC for app-specific tool collections
|
|
6
|
+
- SessionBackend: Protocol for pluggable session storage
|
|
7
|
+
- ToolkitRegistry: Global registry for toolkit discovery
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
|
|
12
|
+
from .agent import ChatAgent, CompletionBackend
|
|
13
|
+
from .models import (
|
|
14
|
+
AgentContext,
|
|
15
|
+
AgentResponse,
|
|
16
|
+
ChatMessage,
|
|
17
|
+
ChatSession,
|
|
18
|
+
ToolResult,
|
|
19
|
+
)
|
|
20
|
+
from .session import (
|
|
21
|
+
InMemorySessionBackend,
|
|
22
|
+
RedisSessionBackend,
|
|
23
|
+
SessionBackend,
|
|
24
|
+
)
|
|
25
|
+
from .composite import CompositeToolkit
|
|
26
|
+
from .toolkit import DomainToolkit
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"__version__",
|
|
30
|
+
# Agent
|
|
31
|
+
"ChatAgent",
|
|
32
|
+
"CompletionBackend",
|
|
33
|
+
# Models
|
|
34
|
+
"AgentContext",
|
|
35
|
+
"AgentResponse",
|
|
36
|
+
"ChatMessage",
|
|
37
|
+
"ChatSession",
|
|
38
|
+
"ToolResult",
|
|
39
|
+
# Toolkit
|
|
40
|
+
"CompositeToolkit",
|
|
41
|
+
"DomainToolkit",
|
|
42
|
+
# Session
|
|
43
|
+
"SessionBackend",
|
|
44
|
+
"InMemorySessionBackend",
|
|
45
|
+
"RedisSessionBackend",
|
|
46
|
+
]
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""ChatAgent — domain-agnostic Tool-Use agent.
|
|
2
|
+
|
|
3
|
+
The core loop: user message → LLM → (tool calls → execute → LLM)* → response.
|
|
4
|
+
|
|
5
|
+
Decoupled from any specific LLM client via the CompletionBackend protocol.
|
|
6
|
+
Apps provide domain logic via DomainToolkit.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any, Protocol, runtime_checkable
|
|
15
|
+
|
|
16
|
+
from .models import AgentContext, AgentResponse, ChatSession, ToolResult
|
|
17
|
+
from .session import SessionBackend
|
|
18
|
+
from .toolkit import DomainToolkit
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@runtime_checkable
|
|
24
|
+
class CompletionBackend(Protocol):
|
|
25
|
+
"""Protocol for LLM completion with tool-use.
|
|
26
|
+
|
|
27
|
+
Matches creative_services.DynamicLLMClient.complete() signature.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
async def complete(
|
|
31
|
+
self,
|
|
32
|
+
messages: list[dict[str, Any]],
|
|
33
|
+
tools: list[dict[str, Any]] | None = None,
|
|
34
|
+
tool_choice: str = "auto",
|
|
35
|
+
**kwargs: Any,
|
|
36
|
+
) -> Any:
|
|
37
|
+
"""Call LLM with messages and optional tools.
|
|
38
|
+
|
|
39
|
+
Must return an object with:
|
|
40
|
+
- content: str | None
|
|
41
|
+
- tool_calls: list with .id, .name, .arguments
|
|
42
|
+
- has_tool_calls: bool
|
|
43
|
+
"""
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ChatAgent:
|
|
49
|
+
"""Domain-agnostic Tool-Use agent.
|
|
50
|
+
|
|
51
|
+
Extracted from travel-beat ConversationalTripAgent,
|
|
52
|
+
generalized for all apps per ADR-034 §3.
|
|
53
|
+
|
|
54
|
+
Usage::
|
|
55
|
+
|
|
56
|
+
agent = ChatAgent(
|
|
57
|
+
toolkit=CADToolkit(db_pool),
|
|
58
|
+
completion=DynamicLLMClient(registry),
|
|
59
|
+
session_backend=RedisSessionBackend(redis),
|
|
60
|
+
system_prompt="You are a CAD assistant...",
|
|
61
|
+
)
|
|
62
|
+
response = await agent.chat(
|
|
63
|
+
session_id="cad-user-123",
|
|
64
|
+
user_message="Wie viele tragende Wände im 2.OG?",
|
|
65
|
+
user=request.user,
|
|
66
|
+
)
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
toolkit: DomainToolkit
|
|
70
|
+
completion: CompletionBackend
|
|
71
|
+
session_backend: SessionBackend
|
|
72
|
+
system_prompt: str
|
|
73
|
+
max_rounds: int = 10
|
|
74
|
+
action_code: str = "chat"
|
|
75
|
+
|
|
76
|
+
async def chat(
|
|
77
|
+
self,
|
|
78
|
+
session_id: str,
|
|
79
|
+
user_message: str,
|
|
80
|
+
*,
|
|
81
|
+
user: Any = None,
|
|
82
|
+
tenant_id: str | None = None,
|
|
83
|
+
metadata: dict[str, Any] | None = None,
|
|
84
|
+
) -> AgentResponse:
|
|
85
|
+
"""Process a user message through the Tool-Use loop.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
session_id: Unique session identifier.
|
|
89
|
+
user_message: The user's natural language input.
|
|
90
|
+
user: Authenticated user object (passed to tools).
|
|
91
|
+
tenant_id: Tenant ID for multi-tenant isolation.
|
|
92
|
+
metadata: Additional context for tool execution.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
AgentResponse with the final text and stats.
|
|
96
|
+
"""
|
|
97
|
+
session = await self.session_backend.load(session_id)
|
|
98
|
+
if session is None:
|
|
99
|
+
session = ChatSession(
|
|
100
|
+
id=session_id,
|
|
101
|
+
messages=[
|
|
102
|
+
{"role": "system", "content": self.system_prompt}
|
|
103
|
+
],
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
session.messages.append(
|
|
107
|
+
{"role": "user", "content": user_message}
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
ctx = AgentContext(
|
|
111
|
+
user=user,
|
|
112
|
+
tenant_id=tenant_id,
|
|
113
|
+
session_id=session_id,
|
|
114
|
+
metadata=metadata or {},
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
total_tool_calls = 0
|
|
118
|
+
content: str | None = None
|
|
119
|
+
error: str | None = None
|
|
120
|
+
model: str = ""
|
|
121
|
+
round_num = 0
|
|
122
|
+
|
|
123
|
+
for round_num in range(self.max_rounds):
|
|
124
|
+
try:
|
|
125
|
+
result = await self.completion.complete(
|
|
126
|
+
messages=session.messages,
|
|
127
|
+
tools=self.toolkit.tool_schemas,
|
|
128
|
+
tool_choice="auto",
|
|
129
|
+
)
|
|
130
|
+
except Exception as exc:
|
|
131
|
+
logger.exception(
|
|
132
|
+
"Completion backend failed in session %s",
|
|
133
|
+
session_id,
|
|
134
|
+
)
|
|
135
|
+
error = f"LLM call failed: {exc}"
|
|
136
|
+
break
|
|
137
|
+
|
|
138
|
+
# Track model if available
|
|
139
|
+
if hasattr(result, "model") and result.model:
|
|
140
|
+
model = result.model
|
|
141
|
+
|
|
142
|
+
if not result.has_tool_calls:
|
|
143
|
+
content = result.content
|
|
144
|
+
session.messages.append(
|
|
145
|
+
{"role": "assistant", "content": content}
|
|
146
|
+
)
|
|
147
|
+
break
|
|
148
|
+
|
|
149
|
+
# Build assistant message with tool calls
|
|
150
|
+
assistant_msg = _build_assistant_msg(result)
|
|
151
|
+
session.messages.append(assistant_msg)
|
|
152
|
+
|
|
153
|
+
# Execute each tool call
|
|
154
|
+
for tc in result.tool_calls:
|
|
155
|
+
total_tool_calls += 1
|
|
156
|
+
tool_result = await self._execute_tool(
|
|
157
|
+
tc, ctx
|
|
158
|
+
)
|
|
159
|
+
tool_msg = _build_tool_result_msg(
|
|
160
|
+
tc.id, tc.name, tool_result
|
|
161
|
+
)
|
|
162
|
+
session.messages.append(tool_msg)
|
|
163
|
+
else:
|
|
164
|
+
error = (
|
|
165
|
+
f"Max tool rounds ({self.max_rounds}) exceeded"
|
|
166
|
+
)
|
|
167
|
+
logger.warning(
|
|
168
|
+
"Max rounds (%d) reached for session %s",
|
|
169
|
+
self.max_rounds,
|
|
170
|
+
session_id,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
await self.session_backend.save(session)
|
|
174
|
+
|
|
175
|
+
return AgentResponse(
|
|
176
|
+
content=content,
|
|
177
|
+
rounds=round_num + 1,
|
|
178
|
+
tool_calls_made=total_tool_calls,
|
|
179
|
+
error=error,
|
|
180
|
+
model=model,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
async def _execute_tool(
|
|
184
|
+
self,
|
|
185
|
+
tool_call: Any,
|
|
186
|
+
ctx: AgentContext,
|
|
187
|
+
) -> ToolResult:
|
|
188
|
+
"""Execute a single tool call with error handling."""
|
|
189
|
+
try:
|
|
190
|
+
return await self.toolkit.execute(
|
|
191
|
+
tool_name=tool_call.name,
|
|
192
|
+
arguments=tool_call.arguments,
|
|
193
|
+
ctx=ctx,
|
|
194
|
+
)
|
|
195
|
+
except Exception as exc:
|
|
196
|
+
logger.exception(
|
|
197
|
+
"Tool %s failed: %s", tool_call.name, exc
|
|
198
|
+
)
|
|
199
|
+
return ToolResult(
|
|
200
|
+
success=False,
|
|
201
|
+
data=None,
|
|
202
|
+
error=f"Tool execution failed: {exc}",
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _build_assistant_msg(result: Any) -> dict[str, Any]:
|
|
207
|
+
"""Build assistant message dict from completion result."""
|
|
208
|
+
msg: dict[str, Any] = {
|
|
209
|
+
"role": "assistant",
|
|
210
|
+
"content": result.content,
|
|
211
|
+
}
|
|
212
|
+
if result.tool_calls:
|
|
213
|
+
msg["tool_calls"] = [
|
|
214
|
+
{
|
|
215
|
+
"id": tc.id,
|
|
216
|
+
"type": "function",
|
|
217
|
+
"function": {
|
|
218
|
+
"name": tc.name,
|
|
219
|
+
"arguments": json.dumps(tc.arguments),
|
|
220
|
+
},
|
|
221
|
+
}
|
|
222
|
+
for tc in result.tool_calls
|
|
223
|
+
]
|
|
224
|
+
return msg
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _build_tool_result_msg(
|
|
228
|
+
tool_call_id: str,
|
|
229
|
+
tool_name: str,
|
|
230
|
+
result: ToolResult,
|
|
231
|
+
) -> dict[str, Any]:
|
|
232
|
+
"""Build tool result message dict for the LLM."""
|
|
233
|
+
if result.success:
|
|
234
|
+
content = json.dumps(result.data, default=str)
|
|
235
|
+
else:
|
|
236
|
+
content = json.dumps({"error": result.error})
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
"role": "tool",
|
|
240
|
+
"tool_call_id": tool_call_id,
|
|
241
|
+
"name": tool_name,
|
|
242
|
+
"content": content,
|
|
243
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""CompositeToolkit — merges multiple DomainToolkits into one.
|
|
2
|
+
|
|
3
|
+
Allows ChatAgent to use tools from multiple domains
|
|
4
|
+
(e.g. TravelBeatToolkit + StoryToolkit) in a single session.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .models import AgentContext, ToolResult
|
|
13
|
+
from .toolkit import DomainToolkit
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CompositeToolkit(DomainToolkit):
|
|
19
|
+
"""Merges multiple DomainToolkits into a single toolkit.
|
|
20
|
+
|
|
21
|
+
Tool dispatch is resolved at init time by mapping each
|
|
22
|
+
tool name to its owning toolkit. Collisions raise ValueError.
|
|
23
|
+
|
|
24
|
+
Usage::
|
|
25
|
+
|
|
26
|
+
composite = CompositeToolkit([
|
|
27
|
+
TravelBeatToolkit(),
|
|
28
|
+
StoryToolkit(),
|
|
29
|
+
])
|
|
30
|
+
agent = ChatAgent(toolkit=composite, ...)
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self, toolkits: list[DomainToolkit],
|
|
35
|
+
) -> None:
|
|
36
|
+
self._toolkits = list(toolkits)
|
|
37
|
+
self._dispatch: dict[str, DomainToolkit] = {}
|
|
38
|
+
|
|
39
|
+
for tk in self._toolkits:
|
|
40
|
+
for schema in tk.tool_schemas:
|
|
41
|
+
tool_name = schema["function"]["name"]
|
|
42
|
+
if tool_name in self._dispatch:
|
|
43
|
+
existing = self._dispatch[tool_name].name
|
|
44
|
+
raise ValueError(
|
|
45
|
+
f"Tool name collision: '{tool_name}' "
|
|
46
|
+
f"exists in both '{existing}' and "
|
|
47
|
+
f"'{tk.name}' toolkits."
|
|
48
|
+
)
|
|
49
|
+
self._dispatch[tool_name] = tk
|
|
50
|
+
|
|
51
|
+
logger.info(
|
|
52
|
+
"CompositeToolkit created: %s (%d tools)",
|
|
53
|
+
self.name,
|
|
54
|
+
len(self._dispatch),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def name(self) -> str:
|
|
59
|
+
return "+".join(tk.name for tk in self._toolkits)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def tool_schemas(self) -> list[dict[str, Any]]:
|
|
63
|
+
schemas: list[dict[str, Any]] = []
|
|
64
|
+
for tk in self._toolkits:
|
|
65
|
+
schemas.extend(tk.tool_schemas)
|
|
66
|
+
return schemas
|
|
67
|
+
|
|
68
|
+
async def execute(
|
|
69
|
+
self,
|
|
70
|
+
tool_name: str,
|
|
71
|
+
arguments: dict[str, Any],
|
|
72
|
+
ctx: AgentContext,
|
|
73
|
+
) -> ToolResult:
|
|
74
|
+
"""Dispatch tool call to the owning toolkit."""
|
|
75
|
+
tk = self._dispatch.get(tool_name)
|
|
76
|
+
if not tk:
|
|
77
|
+
return ToolResult(
|
|
78
|
+
success=False,
|
|
79
|
+
data=None,
|
|
80
|
+
error=(
|
|
81
|
+
f"Unknown tool: '{tool_name}'. "
|
|
82
|
+
f"Available: {list(self._dispatch.keys())}"
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
return await tk.execute(tool_name, arguments, ctx)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Pydantic models for the chat-agent package.
|
|
2
|
+
|
|
3
|
+
Defines the core data structures for chat sessions, messages,
|
|
4
|
+
tool results, and agent context.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ToolResult(BaseModel):
|
|
15
|
+
"""Result of a single tool execution."""
|
|
16
|
+
|
|
17
|
+
model_config = ConfigDict(frozen=True)
|
|
18
|
+
|
|
19
|
+
success: bool = Field(description="Whether the tool call succeeded")
|
|
20
|
+
data: Any = Field(
|
|
21
|
+
default=None, description="Result data (dict, list, str)"
|
|
22
|
+
)
|
|
23
|
+
error: str | None = Field(
|
|
24
|
+
default=None, description="Error message if failed"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AgentContext(BaseModel):
|
|
29
|
+
"""Context passed to every tool execution."""
|
|
30
|
+
|
|
31
|
+
model_config = ConfigDict(frozen=True)
|
|
32
|
+
|
|
33
|
+
user: Any = Field(
|
|
34
|
+
default=None, description="Authenticated user object"
|
|
35
|
+
)
|
|
36
|
+
tenant_id: str | None = Field(
|
|
37
|
+
default=None, description="Tenant ID for multi-tenancy"
|
|
38
|
+
)
|
|
39
|
+
session_id: str = Field(description="Current chat session ID")
|
|
40
|
+
metadata: dict[str, Any] = Field(
|
|
41
|
+
default_factory=dict,
|
|
42
|
+
description="Additional context (e.g. project_id)",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ChatMessage(BaseModel):
|
|
47
|
+
"""Single message in a chat session."""
|
|
48
|
+
|
|
49
|
+
model_config = ConfigDict(frozen=True)
|
|
50
|
+
|
|
51
|
+
role: str = Field(description="system, user, assistant, or tool")
|
|
52
|
+
content: str | None = Field(
|
|
53
|
+
default=None, description="Text content"
|
|
54
|
+
)
|
|
55
|
+
tool_calls: list[dict[str, Any]] = Field(
|
|
56
|
+
default_factory=list,
|
|
57
|
+
description="Tool calls (assistant role only)",
|
|
58
|
+
)
|
|
59
|
+
tool_call_id: str | None = Field(
|
|
60
|
+
default=None,
|
|
61
|
+
description="ID of the tool call this message responds to",
|
|
62
|
+
)
|
|
63
|
+
name: str | None = Field(
|
|
64
|
+
default=None, description="Tool name (tool role only)"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def to_api_dict(self) -> dict[str, Any]:
|
|
68
|
+
"""Convert to API-compatible dict for LLM calls."""
|
|
69
|
+
d: dict[str, Any] = {"role": self.role}
|
|
70
|
+
if self.content is not None:
|
|
71
|
+
d["content"] = self.content
|
|
72
|
+
if self.tool_calls:
|
|
73
|
+
d["tool_calls"] = self.tool_calls
|
|
74
|
+
if self.tool_call_id is not None:
|
|
75
|
+
d["tool_call_id"] = self.tool_call_id
|
|
76
|
+
if self.name is not None:
|
|
77
|
+
d["name"] = self.name
|
|
78
|
+
return d
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ChatSession(BaseModel):
|
|
82
|
+
"""Persistent chat session with message history."""
|
|
83
|
+
|
|
84
|
+
id: str = Field(description="Unique session identifier")
|
|
85
|
+
messages: list[dict[str, Any]] = Field(
|
|
86
|
+
default_factory=list,
|
|
87
|
+
description="Message history (API-format dicts)",
|
|
88
|
+
)
|
|
89
|
+
metadata: dict[str, Any] = Field(
|
|
90
|
+
default_factory=dict,
|
|
91
|
+
description="Session-level metadata",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class AgentResponse(BaseModel):
|
|
96
|
+
"""Response from ChatAgent.chat()."""
|
|
97
|
+
|
|
98
|
+
model_config = ConfigDict(frozen=True)
|
|
99
|
+
|
|
100
|
+
content: str | None = Field(
|
|
101
|
+
default=None,
|
|
102
|
+
description="Final text response from the agent",
|
|
103
|
+
)
|
|
104
|
+
rounds: int = Field(
|
|
105
|
+
default=1,
|
|
106
|
+
description="Number of LLM rounds used",
|
|
107
|
+
)
|
|
108
|
+
tool_calls_made: int = Field(
|
|
109
|
+
default=0,
|
|
110
|
+
description="Total tool calls executed",
|
|
111
|
+
)
|
|
112
|
+
error: str | None = Field(
|
|
113
|
+
default=None,
|
|
114
|
+
description="Error message if the agent failed",
|
|
115
|
+
)
|
|
116
|
+
model: str = Field(
|
|
117
|
+
default="",
|
|
118
|
+
description="LLM model used for the response",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def success(self) -> bool:
|
|
123
|
+
"""True if no error occurred."""
|
|
124
|
+
return self.error is None
|