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.
@@ -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