feat: add AI coding assistant packages
This commit is contained in:
parent
98d113883e
commit
3213c98fc7
@ -0,0 +1,121 @@
|
||||
"""Agent-internal message types.
|
||||
|
||||
All message types share a common `to_dict()` / `from_dict()` interface for
|
||||
serialization. `AgentMessage.from_dict()` is a factory that dispatches to the
|
||||
correct concrete class based on the ``role`` field.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ToolCall:
|
||||
"""A single tool call requested by the assistant."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
arguments: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {"id": self.id, "name": self.name, "arguments": self.arguments}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> ToolCall:
|
||||
return cls(
|
||||
id=data["id"],
|
||||
name=data["name"],
|
||||
arguments=data.get("arguments", {}),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SystemMessage:
|
||||
role: str = field(default="system", init=False)
|
||||
content: str
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {"role": self.role, "content": self.content}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> SystemMessage:
|
||||
return cls(content=data["content"])
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class UserMessage:
|
||||
role: str = field(default="user", init=False)
|
||||
content: str
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {"role": self.role, "content": self.content}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> UserMessage:
|
||||
return cls(content=data["content"])
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AssistantMessage:
|
||||
role: str = field(default="assistant", init=False)
|
||||
content: str
|
||||
tool_calls: list[ToolCall] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"role": self.role,
|
||||
"content": self.content,
|
||||
"tool_calls": [tc.to_dict() for tc in self.tool_calls],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> AssistantMessage:
|
||||
tool_calls = [
|
||||
ToolCall.from_dict(tc) for tc in data.get("tool_calls", [])
|
||||
]
|
||||
return cls(content=data.get("content", ""), tool_calls=tool_calls)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ToolResultMessage:
|
||||
role: str = field(default="tool", init=False)
|
||||
tool_call_id: str
|
||||
content: str
|
||||
is_error: bool = False
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"role": self.role,
|
||||
"tool_call_id": self.tool_call_id,
|
||||
"content": self.content,
|
||||
"is_error": self.is_error,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> ToolResultMessage:
|
||||
return cls(
|
||||
tool_call_id=data["tool_call_id"],
|
||||
content=data["content"],
|
||||
is_error=data.get("is_error", False),
|
||||
)
|
||||
|
||||
|
||||
# Union type for type annotations
|
||||
AgentMessage = SystemMessage | UserMessage | AssistantMessage | ToolResultMessage
|
||||
|
||||
|
||||
def agent_message_from_dict(data: dict[str, Any]) -> AgentMessage:
|
||||
"""Factory: create the correct AgentMessage subclass from a dict."""
|
||||
role = data.get("role")
|
||||
dispatch: dict[str, type[AgentMessage]] = {
|
||||
"system": SystemMessage,
|
||||
"user": UserMessage,
|
||||
"assistant": AssistantMessage,
|
||||
"tool": ToolResultMessage,
|
||||
}
|
||||
cls = dispatch.get(role)
|
||||
if cls is None:
|
||||
raise ValueError(f"unknown role: {role!r}")
|
||||
return cls.from_dict(data)
|
||||
@ -0,0 +1,109 @@
|
||||
"""LLM Provider abstraction — types and ABC.
|
||||
|
||||
Defines the contract that every LLM provider adapter must implement,
|
||||
plus shared data types for messages, tool definitions, and stream chunks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
# ── LLM API message format ───────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Message:
|
||||
"""A message in the LLM provider's native API format."""
|
||||
|
||||
role: str # "system" | "user" | "assistant" | "tool"
|
||||
content: str | None = None
|
||||
tool_calls: list[dict[str, Any]] | None = None
|
||||
tool_call_id: str | None = None
|
||||
name: str | None = None
|
||||
|
||||
|
||||
# ── Tool definition types ────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ToolParameter:
|
||||
"""Describes a single parameter of a tool."""
|
||||
|
||||
type: str
|
||||
description: str = ""
|
||||
required: bool = True
|
||||
enum: list[str] | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ToolDef:
|
||||
"""Tool definition passed to LLM providers."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
parameters: dict[str, ToolParameter] = field(default_factory=dict)
|
||||
|
||||
def to_openai_format(self) -> dict[str, Any]:
|
||||
"""Convert to OpenAI function-calling tool format."""
|
||||
properties: dict[str, Any] = {}
|
||||
required: list[str] = []
|
||||
for pname, param in self.parameters.items():
|
||||
prop: dict[str, Any] = {"type": param.type, "description": param.description}
|
||||
if param.enum is not None:
|
||||
prop["enum"] = param.enum
|
||||
properties[pname] = prop
|
||||
if param.required:
|
||||
required.append(pname)
|
||||
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"required": required,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── Stream chunk ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class StreamChunk:
|
||||
"""A single chunk emitted during LLM streaming."""
|
||||
|
||||
type: str # "content" | "tool_call" | "done" | "error"
|
||||
content: str | None = None
|
||||
tool_call: dict[str, Any] | None = None
|
||||
|
||||
|
||||
# ── LLM Provider ABC ─────────────────────────────────────────
|
||||
|
||||
|
||||
class LLMProvider(ABC):
|
||||
"""Abstract base class for LLM provider adapters."""
|
||||
|
||||
@abstractmethod
|
||||
async def stream_chat(
|
||||
self,
|
||||
messages: list[Message],
|
||||
tools: list[ToolDef] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterator[StreamChunk]:
|
||||
"""Stream a chat completion from the LLM."""
|
||||
|
||||
@abstractmethod
|
||||
def convert_messages(self, agent_messages: list[Any]) -> list[Message]:
|
||||
"""Convert agent-internal messages to the provider's Message format."""
|
||||
|
||||
@abstractmethod
|
||||
def convert_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:
|
||||
"""Convert ToolDef list to the provider's native tool format."""
|
||||
30
ai-coding-assistant/pyproject.toml
Normal file
30
ai-coding-assistant/pyproject.toml
Normal file
@ -0,0 +1,30 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "ai-coding-assistant"
|
||||
version = "0.1.0"
|
||||
description = "An extensible AI coding assistant with multi-LLM support"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"httpx>=0.27",
|
||||
"pydantic>=2.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.23",
|
||||
"respx>=0.21",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
ai-ca = "ai_coding_assistant.cli:main"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["packages/core/ai_coding_assistant"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
0
ai-coding-assistant/tests/__init__.py
Normal file
0
ai-coding-assistant/tests/__init__.py
Normal file
0
ai-coding-assistant/tests/core/__init__.py
Normal file
0
ai-coding-assistant/tests/core/__init__.py
Normal file
0
ai-coding-assistant/tests/core/agent/__init__.py
Normal file
0
ai-coding-assistant/tests/core/agent/__init__.py
Normal file
156
ai-coding-assistant/tests/core/agent/test_messages.py
Normal file
156
ai-coding-assistant/tests/core/agent/test_messages.py
Normal file
@ -0,0 +1,156 @@
|
||||
"""Tests for agent message models — RED phase."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from ai_coding_assistant.agent.messages import (
|
||||
AgentMessage,
|
||||
AssistantMessage,
|
||||
SystemMessage,
|
||||
ToolCall,
|
||||
ToolResultMessage,
|
||||
UserMessage,
|
||||
agent_message_from_dict,
|
||||
)
|
||||
|
||||
|
||||
# ── Construction ──────────────────────────────────────────────
|
||||
|
||||
class TestUserMessage:
|
||||
def test_create(self):
|
||||
msg = UserMessage(content="hello")
|
||||
assert msg.role == "user"
|
||||
assert msg.content == "hello"
|
||||
|
||||
def test_to_dict(self):
|
||||
msg = UserMessage(content="hello")
|
||||
d = msg.to_dict()
|
||||
assert d == {"role": "user", "content": "hello"}
|
||||
|
||||
def test_from_dict(self):
|
||||
msg = UserMessage.from_dict({"role": "user", "content": "hello"})
|
||||
assert msg.role == "user"
|
||||
assert msg.content == "hello"
|
||||
|
||||
|
||||
class TestSystemMessage:
|
||||
def test_create(self):
|
||||
msg = SystemMessage(content="you are helpful")
|
||||
assert msg.role == "system"
|
||||
assert msg.content == "you are helpful"
|
||||
|
||||
def test_to_dict_roundtrip(self):
|
||||
original = SystemMessage(content="you are helpful")
|
||||
restored = SystemMessage.from_dict(original.to_dict())
|
||||
assert restored.content == original.content
|
||||
|
||||
|
||||
class TestAssistantMessage:
|
||||
def test_create_text_only(self):
|
||||
msg = AssistantMessage(content="hi there")
|
||||
assert msg.role == "assistant"
|
||||
assert msg.content == "hi there"
|
||||
assert msg.tool_calls == []
|
||||
|
||||
def test_create_with_tool_calls(self):
|
||||
tc = ToolCall(id="call_1", name="echo", arguments={"text": "hello"})
|
||||
msg = AssistantMessage(content="", tool_calls=[tc])
|
||||
assert len(msg.tool_calls) == 1
|
||||
assert msg.tool_calls[0].name == "echo"
|
||||
|
||||
def test_to_dict_with_tool_calls(self):
|
||||
tc = ToolCall(id="call_1", name="echo", arguments={"text": "hello"})
|
||||
msg = AssistantMessage(content="", tool_calls=[tc])
|
||||
d = msg.to_dict()
|
||||
assert d["role"] == "assistant"
|
||||
assert len(d["tool_calls"]) == 1
|
||||
assert d["tool_calls"][0]["id"] == "call_1"
|
||||
|
||||
def test_from_dict_with_tool_calls(self):
|
||||
data = {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"id": "call_1", "name": "echo", "arguments": {"text": "hello"}}
|
||||
],
|
||||
}
|
||||
msg = AssistantMessage.from_dict(data)
|
||||
assert msg.role == "assistant"
|
||||
assert len(msg.tool_calls) == 1
|
||||
assert msg.tool_calls[0].name == "echo"
|
||||
assert msg.tool_calls[0].arguments == {"text": "hello"}
|
||||
|
||||
def test_roundtrip(self):
|
||||
tc = ToolCall(id="call_1", name="echo", arguments={"text": "hello"})
|
||||
original = AssistantMessage(content="thinking...", tool_calls=[tc])
|
||||
restored = AssistantMessage.from_dict(original.to_dict())
|
||||
assert restored.content == original.content
|
||||
assert len(restored.tool_calls) == 1
|
||||
assert restored.tool_calls[0].id == tc.id
|
||||
|
||||
|
||||
class TestToolResultMessage:
|
||||
def test_create(self):
|
||||
msg = ToolResultMessage(
|
||||
tool_call_id="call_1", content="result text", is_error=False
|
||||
)
|
||||
assert msg.role == "tool"
|
||||
assert msg.tool_call_id == "call_1"
|
||||
assert msg.is_error is False
|
||||
|
||||
def test_create_error(self):
|
||||
msg = ToolResultMessage(
|
||||
tool_call_id="call_2", content="something failed", is_error=True
|
||||
)
|
||||
assert msg.is_error is True
|
||||
|
||||
def test_to_dict_roundtrip(self):
|
||||
original = ToolResultMessage(
|
||||
tool_call_id="call_1", content="result text", is_error=False
|
||||
)
|
||||
restored = ToolResultMessage.from_dict(original.to_dict())
|
||||
assert restored.tool_call_id == original.tool_call_id
|
||||
assert restored.content == original.content
|
||||
assert restored.is_error == original.is_error
|
||||
|
||||
|
||||
# ── AgentMessage union helpers ────────────────────────────────
|
||||
|
||||
class TestAgentMessageUnion:
|
||||
def test_from_dict_user(self):
|
||||
msg = agent_message_from_dict({"role": "user", "content": "hi"})
|
||||
assert isinstance(msg, UserMessage)
|
||||
|
||||
def test_from_dict_system(self):
|
||||
msg = agent_message_from_dict({"role": "system", "content": "sys"})
|
||||
assert isinstance(msg, SystemMessage)
|
||||
|
||||
def test_from_dict_assistant(self):
|
||||
msg = agent_message_from_dict({"role": "assistant", "content": "hi"})
|
||||
assert isinstance(msg, AssistantMessage)
|
||||
|
||||
def test_from_dict_tool(self):
|
||||
msg = agent_message_from_dict(
|
||||
{"role": "tool", "tool_call_id": "c1", "content": "ok", "is_error": False}
|
||||
)
|
||||
assert isinstance(msg, ToolResultMessage)
|
||||
|
||||
def test_from_dict_unknown_role_raises(self):
|
||||
with pytest.raises(ValueError, match="unknown role"):
|
||||
agent_message_from_dict({"role": "alien", "content": "???"})
|
||||
|
||||
def test_json_serializable(self):
|
||||
"""All message types should be JSON-serializable via to_dict()."""
|
||||
tc = ToolCall(id="call_1", name="echo", arguments={"text": "hi"})
|
||||
msgs: list[AgentMessage] = [
|
||||
SystemMessage(content="system"),
|
||||
UserMessage(content="hello"),
|
||||
AssistantMessage(content="", tool_calls=[tc]),
|
||||
ToolResultMessage(tool_call_id="call_1", content="hi", is_error=False),
|
||||
]
|
||||
for msg in msgs:
|
||||
json_str = json.dumps(msg.to_dict())
|
||||
assert json_str # non-empty
|
||||
0
ai-coding-assistant/tests/core/context/__init__.py
Normal file
0
ai-coding-assistant/tests/core/context/__init__.py
Normal file
0
ai-coding-assistant/tests/core/llm/__init__.py
Normal file
0
ai-coding-assistant/tests/core/llm/__init__.py
Normal file
147
ai-coding-assistant/tests/core/llm/test_base.py
Normal file
147
ai-coding-assistant/tests/core/llm/test_base.py
Normal file
@ -0,0 +1,147 @@
|
||||
"""Tests for LLM Provider abstraction — RED phase."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from ai_coding_assistant.llm.base import (
|
||||
LLMProvider,
|
||||
Message,
|
||||
StreamChunk,
|
||||
ToolDef,
|
||||
ToolParameter,
|
||||
)
|
||||
|
||||
|
||||
# ── Data model construction ──────────────────────────────────
|
||||
|
||||
class TestMessage:
|
||||
def test_user_message(self):
|
||||
msg = Message(role="user", content="hello")
|
||||
assert msg.role == "user"
|
||||
assert msg.content == "hello"
|
||||
assert msg.tool_calls is None
|
||||
|
||||
def test_assistant_with_tool_calls(self):
|
||||
msg = Message(
|
||||
role="assistant",
|
||||
content="",
|
||||
tool_calls=[{"id": "c1", "name": "echo", "arguments": {"text": "hi"}}],
|
||||
)
|
||||
assert len(msg.tool_calls) == 1
|
||||
assert msg.tool_calls[0]["name"] == "echo"
|
||||
|
||||
def test_system_message(self):
|
||||
msg = Message(role="system", content="you are helpful")
|
||||
assert msg.role == "system"
|
||||
|
||||
def test_tool_message(self):
|
||||
msg = Message(
|
||||
role="tool",
|
||||
content="result",
|
||||
tool_call_id="c1",
|
||||
)
|
||||
assert msg.role == "tool"
|
||||
assert msg.tool_call_id == "c1"
|
||||
|
||||
|
||||
class TestToolParameter:
|
||||
def test_create(self):
|
||||
param = ToolParameter(
|
||||
type="string",
|
||||
description="text to echo",
|
||||
)
|
||||
assert param.type == "string"
|
||||
|
||||
def test_with_enum(self):
|
||||
param = ToolParameter(
|
||||
type="string",
|
||||
description="mode",
|
||||
enum=["fast", "slow"],
|
||||
)
|
||||
assert param.enum == ["fast", "slow"]
|
||||
|
||||
|
||||
class TestToolDef:
|
||||
def test_create(self):
|
||||
td = ToolDef(
|
||||
name="echo",
|
||||
description="Echo back the input",
|
||||
parameters={
|
||||
"text": ToolParameter(type="string", description="text to echo"),
|
||||
},
|
||||
)
|
||||
assert td.name == "echo"
|
||||
assert "text" in td.parameters
|
||||
|
||||
def test_to_openai_format(self):
|
||||
td = ToolDef(
|
||||
name="echo",
|
||||
description="Echo back the input",
|
||||
parameters={
|
||||
"text": ToolParameter(type="string", description="text to echo"),
|
||||
},
|
||||
)
|
||||
result = td.to_openai_format()
|
||||
assert result["type"] == "function"
|
||||
assert result["function"]["name"] == "echo"
|
||||
assert "text" in result["function"]["parameters"]["properties"]
|
||||
|
||||
|
||||
class TestStreamChunk:
|
||||
def test_content_chunk(self):
|
||||
chunk = StreamChunk(type="content", content="Hello")
|
||||
assert chunk.type == "content"
|
||||
assert chunk.content == "Hello"
|
||||
assert chunk.tool_call is None
|
||||
|
||||
def test_tool_call_chunk(self):
|
||||
chunk = StreamChunk(
|
||||
type="tool_call",
|
||||
tool_call={"id": "c1", "name": "echo", "arguments": "{}"},
|
||||
)
|
||||
assert chunk.type == "tool_call"
|
||||
assert chunk.tool_call["name"] == "echo"
|
||||
|
||||
def test_done_chunk(self):
|
||||
chunk = StreamChunk(type="done")
|
||||
assert chunk.type == "done"
|
||||
|
||||
def test_error_chunk(self):
|
||||
chunk = StreamChunk(type="error", content="rate limited")
|
||||
assert chunk.type == "error"
|
||||
assert chunk.content == "rate limited"
|
||||
|
||||
|
||||
# ── LLMProvider ABC ──────────────────────────────────────────
|
||||
|
||||
class TestLLMProviderABC:
|
||||
def test_cannot_instantiate(self):
|
||||
"""LLMProvider is abstract and cannot be instantiated directly."""
|
||||
with pytest.raises(TypeError):
|
||||
LLMProvider() # type: ignore[abstract]
|
||||
|
||||
def test_subclass_must_implement_methods(self):
|
||||
"""A subclass that doesn't implement all abstract methods cannot be instantiated."""
|
||||
|
||||
class IncompleteProvider(LLMProvider):
|
||||
pass
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
IncompleteProvider() # type: ignore[abstract]
|
||||
|
||||
def test_subclass_can_be_created_with_all_methods(self):
|
||||
"""A subclass that implements all abstract methods can be instantiated."""
|
||||
|
||||
class DummyProvider(LLMProvider):
|
||||
async def stream_chat(self, messages, tools=None, **kwargs):
|
||||
yield StreamChunk(type="done")
|
||||
|
||||
def convert_messages(self, agent_messages):
|
||||
return []
|
||||
|
||||
def convert_tools(self, tools):
|
||||
return []
|
||||
|
||||
provider = DummyProvider()
|
||||
assert isinstance(provider, LLMProvider)
|
||||
0
ai-coding-assistant/tests/core/skills/__init__.py
Normal file
0
ai-coding-assistant/tests/core/skills/__init__.py
Normal file
0
ai-coding-assistant/tests/core/tools/__init__.py
Normal file
0
ai-coding-assistant/tests/core/tools/__init__.py
Normal file
5
memory/2026-05-13.md
Normal file
5
memory/2026-05-13.md
Normal file
@ -0,0 +1,5 @@
|
||||
# 2026-05-13
|
||||
|
||||
## 纪要
|
||||
|
||||
- 工作区已初始化,可在此持续记录当天上下文。
|
||||
Loading…
x
Reference in New Issue
Block a user