minimal-harness 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.
- minimal_harness-0.1.0/PKG-INFO +136 -0
- minimal_harness-0.1.0/README.md +121 -0
- minimal_harness-0.1.0/pyproject.toml +25 -0
- minimal_harness-0.1.0/src/minimal_harness/__init__.py +24 -0
- minimal_harness-0.1.0/src/minimal_harness/agent.py +100 -0
- minimal_harness-0.1.0/src/minimal_harness/llm/__init__.py +23 -0
- minimal_harness-0.1.0/src/minimal_harness/llm/llm.py +85 -0
- minimal_harness-0.1.0/src/minimal_harness/llm/openai.py +102 -0
- minimal_harness-0.1.0/src/minimal_harness/memory.py +88 -0
- minimal_harness-0.1.0/src/minimal_harness/py.typed +0 -0
- minimal_harness-0.1.0/src/minimal_harness/tool/__init__.py +3 -0
- minimal_harness-0.1.0/src/minimal_harness/tool/base.py +22 -0
- minimal_harness-0.1.0/src/minimal_harness/tool/glob.py +39 -0
- minimal_harness-0.1.0/src/minimal_harness/tool/grep.py +72 -0
- minimal_harness-0.1.0/src/minimal_harness/tool_executor.py +54 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: minimal-harness
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author: J0ey1iu
|
|
6
|
+
Author-email: J0ey1iu <549323981@qq.com>
|
|
7
|
+
Requires-Dist: openai
|
|
8
|
+
Requires-Dist: textual ; extra == 'demo'
|
|
9
|
+
Requires-Dist: pytest>=9.0.2 ; extra == 'test'
|
|
10
|
+
Requires-Dist: pytest-asyncio>=1.3.0 ; extra == 'test'
|
|
11
|
+
Requires-Python: >=3.12
|
|
12
|
+
Provides-Extra: demo
|
|
13
|
+
Provides-Extra: test
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# minimal-harness
|
|
17
|
+
|
|
18
|
+
A lightweight Python agent harness with tool-calling support.
|
|
19
|
+
|
|
20
|
+
## Features
|
|
21
|
+
|
|
22
|
+
- Simple `Agent` class for building LLM-powered agents
|
|
23
|
+
- Tool-calling support with concurrent execution
|
|
24
|
+
- Streaming response support via chunk callbacks
|
|
25
|
+
- Conversation history management with `Memory` interface
|
|
26
|
+
- Built on OpenAI's API (supports any OpenAI-compatible endpoint)
|
|
27
|
+
- Extensible LLM provider interface
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install -e .
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quick Start
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import asyncio
|
|
39
|
+
from minimal_harness import Agent, Tool, OpenAILLMProvider
|
|
40
|
+
from openai import AsyncOpenAI
|
|
41
|
+
|
|
42
|
+
async def get_weather(city: str) -> dict:
|
|
43
|
+
return {"city": city, "temperature": "22°C", "condition": "Sunny"}
|
|
44
|
+
|
|
45
|
+
tools = [
|
|
46
|
+
Tool(
|
|
47
|
+
name="get_weather",
|
|
48
|
+
description="Get weather for a specified city",
|
|
49
|
+
parameters={
|
|
50
|
+
"type": "object",
|
|
51
|
+
"properties": {"city": {"type": "string", "description": "City name"}},
|
|
52
|
+
"required": ["city"],
|
|
53
|
+
},
|
|
54
|
+
fn=get_weather,
|
|
55
|
+
),
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
client = AsyncOpenAI(api_key="your-api-key", base_url="https://aihubmix.com/v1")
|
|
59
|
+
llm_provider = OpenAILLMProvider(client=client, model="minimax-m2.7")
|
|
60
|
+
agent = Agent(llm_provider=llm_provider, tools=tools)
|
|
61
|
+
|
|
62
|
+
async def on_chunk(chunk, is_done):
|
|
63
|
+
if is_done:
|
|
64
|
+
print()
|
|
65
|
+
return
|
|
66
|
+
delta = chunk.choices[0].delta if chunk.choices else None
|
|
67
|
+
if delta and delta.content:
|
|
68
|
+
print(delta.content, end="", flush=True)
|
|
69
|
+
|
|
70
|
+
result = await agent.run("What's the weather in Beijing?", on_chunk=on_chunk)
|
|
71
|
+
print(result)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Agent
|
|
75
|
+
|
|
76
|
+
The `Agent` class manages conversation context and tool execution.
|
|
77
|
+
|
|
78
|
+
### Constructor
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
Agent(
|
|
82
|
+
llm_provider: LLMProvider,
|
|
83
|
+
tools: list[Tool] | None = None,
|
|
84
|
+
max_iterations: int = 10,
|
|
85
|
+
memory: Memory | None = None,
|
|
86
|
+
tool_executor: ToolExecutor | None = None,
|
|
87
|
+
)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Methods
|
|
91
|
+
|
|
92
|
+
- `run(user_input: str, on_chunk: ChunkCallback | None = None) -> str` - Run the agent with user input
|
|
93
|
+
|
|
94
|
+
## LLMProvider
|
|
95
|
+
|
|
96
|
+
The `LLMProvider` is a protocol that defines the interface for LLM backends. The library includes `OpenAILLMProvider` for OpenAI-compatible endpoints.
|
|
97
|
+
|
|
98
|
+
### OpenAILLMProvider
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
OpenAILLMProvider(client: AsyncOpenAI, model: str = "qwen3.5-27b")
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Memory
|
|
105
|
+
|
|
106
|
+
Memory classes manage conversation history.
|
|
107
|
+
|
|
108
|
+
### ConversationMemory
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
ConversationMemory(system_prompt: str = "You are a helpful assistant.")
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Tool
|
|
115
|
+
|
|
116
|
+
Define tools that the agent can call.
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
Tool(
|
|
120
|
+
name: str,
|
|
121
|
+
description: str,
|
|
122
|
+
parameters: dict, # OpenAI function parameters schema
|
|
123
|
+
fn: Callable[..., Awaitable[Any]], # Async function implementation
|
|
124
|
+
)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## ToolExecutor
|
|
128
|
+
|
|
129
|
+
Executes tool calls concurrently and returns results as messages.
|
|
130
|
+
|
|
131
|
+
## Testing
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
pip install -e ".[test]"
|
|
135
|
+
pytest
|
|
136
|
+
```
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# minimal-harness
|
|
2
|
+
|
|
3
|
+
A lightweight Python agent harness with tool-calling support.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Simple `Agent` class for building LLM-powered agents
|
|
8
|
+
- Tool-calling support with concurrent execution
|
|
9
|
+
- Streaming response support via chunk callbacks
|
|
10
|
+
- Conversation history management with `Memory` interface
|
|
11
|
+
- Built on OpenAI's API (supports any OpenAI-compatible endpoint)
|
|
12
|
+
- Extensible LLM provider interface
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install -e .
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Quick Start
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
import asyncio
|
|
24
|
+
from minimal_harness import Agent, Tool, OpenAILLMProvider
|
|
25
|
+
from openai import AsyncOpenAI
|
|
26
|
+
|
|
27
|
+
async def get_weather(city: str) -> dict:
|
|
28
|
+
return {"city": city, "temperature": "22°C", "condition": "Sunny"}
|
|
29
|
+
|
|
30
|
+
tools = [
|
|
31
|
+
Tool(
|
|
32
|
+
name="get_weather",
|
|
33
|
+
description="Get weather for a specified city",
|
|
34
|
+
parameters={
|
|
35
|
+
"type": "object",
|
|
36
|
+
"properties": {"city": {"type": "string", "description": "City name"}},
|
|
37
|
+
"required": ["city"],
|
|
38
|
+
},
|
|
39
|
+
fn=get_weather,
|
|
40
|
+
),
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
client = AsyncOpenAI(api_key="your-api-key", base_url="https://aihubmix.com/v1")
|
|
44
|
+
llm_provider = OpenAILLMProvider(client=client, model="minimax-m2.7")
|
|
45
|
+
agent = Agent(llm_provider=llm_provider, tools=tools)
|
|
46
|
+
|
|
47
|
+
async def on_chunk(chunk, is_done):
|
|
48
|
+
if is_done:
|
|
49
|
+
print()
|
|
50
|
+
return
|
|
51
|
+
delta = chunk.choices[0].delta if chunk.choices else None
|
|
52
|
+
if delta and delta.content:
|
|
53
|
+
print(delta.content, end="", flush=True)
|
|
54
|
+
|
|
55
|
+
result = await agent.run("What's the weather in Beijing?", on_chunk=on_chunk)
|
|
56
|
+
print(result)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Agent
|
|
60
|
+
|
|
61
|
+
The `Agent` class manages conversation context and tool execution.
|
|
62
|
+
|
|
63
|
+
### Constructor
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
Agent(
|
|
67
|
+
llm_provider: LLMProvider,
|
|
68
|
+
tools: list[Tool] | None = None,
|
|
69
|
+
max_iterations: int = 10,
|
|
70
|
+
memory: Memory | None = None,
|
|
71
|
+
tool_executor: ToolExecutor | None = None,
|
|
72
|
+
)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Methods
|
|
76
|
+
|
|
77
|
+
- `run(user_input: str, on_chunk: ChunkCallback | None = None) -> str` - Run the agent with user input
|
|
78
|
+
|
|
79
|
+
## LLMProvider
|
|
80
|
+
|
|
81
|
+
The `LLMProvider` is a protocol that defines the interface for LLM backends. The library includes `OpenAILLMProvider` for OpenAI-compatible endpoints.
|
|
82
|
+
|
|
83
|
+
### OpenAILLMProvider
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
OpenAILLMProvider(client: AsyncOpenAI, model: str = "qwen3.5-27b")
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Memory
|
|
90
|
+
|
|
91
|
+
Memory classes manage conversation history.
|
|
92
|
+
|
|
93
|
+
### ConversationMemory
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
ConversationMemory(system_prompt: str = "You are a helpful assistant.")
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Tool
|
|
100
|
+
|
|
101
|
+
Define tools that the agent can call.
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
Tool(
|
|
105
|
+
name: str,
|
|
106
|
+
description: str,
|
|
107
|
+
parameters: dict, # OpenAI function parameters schema
|
|
108
|
+
fn: Callable[..., Awaitable[Any]], # Async function implementation
|
|
109
|
+
)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## ToolExecutor
|
|
113
|
+
|
|
114
|
+
Executes tool calls concurrently and returns results as messages.
|
|
115
|
+
|
|
116
|
+
## Testing
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
pip install -e ".[test]"
|
|
120
|
+
pytest
|
|
121
|
+
```
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "minimal-harness"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "J0ey1iu", email = "549323981@qq.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"openai"
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
test = [
|
|
16
|
+
"pytest>=9.0.2",
|
|
17
|
+
"pytest-asyncio>=1.3.0",
|
|
18
|
+
]
|
|
19
|
+
demo = [
|
|
20
|
+
"textual",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["uv_build>=0.10.12,<0.11.0"]
|
|
25
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from .agent import OpenAIAgent
|
|
2
|
+
from .llm import LLMProvider, LLMResponse, OpenAILLMProvider, Stream
|
|
3
|
+
from .memory import (
|
|
4
|
+
InputContentPart,
|
|
5
|
+
Memory,
|
|
6
|
+
ConversationMemory,
|
|
7
|
+
TextContentPart,
|
|
8
|
+
)
|
|
9
|
+
from .tool import Tool
|
|
10
|
+
from .tool_executor import ToolExecutor
|
|
11
|
+
|
|
12
|
+
__ALL__ = [
|
|
13
|
+
OpenAIAgent,
|
|
14
|
+
LLMProvider,
|
|
15
|
+
LLMResponse,
|
|
16
|
+
Stream,
|
|
17
|
+
Memory,
|
|
18
|
+
ConversationMemory,
|
|
19
|
+
OpenAILLMProvider,
|
|
20
|
+
Tool,
|
|
21
|
+
ToolExecutor,
|
|
22
|
+
InputContentPart,
|
|
23
|
+
TextContentPart,
|
|
24
|
+
]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from typing import Awaitable, Callable, Iterable, Protocol, cast
|
|
2
|
+
|
|
3
|
+
from openai.types.chat import ChatCompletionChunk
|
|
4
|
+
|
|
5
|
+
from minimal_harness.llm import ChunkCallback, ToolResultCallback
|
|
6
|
+
from minimal_harness.llm.openai import OpenAILLMProvider
|
|
7
|
+
from minimal_harness.memory import (
|
|
8
|
+
ExtendedInputContentPart,
|
|
9
|
+
InputContentPart,
|
|
10
|
+
ConversationMemory,
|
|
11
|
+
Memory,
|
|
12
|
+
Message,
|
|
13
|
+
UserMessage,
|
|
14
|
+
)
|
|
15
|
+
from minimal_harness.tool import Tool
|
|
16
|
+
from minimal_harness.tool_executor import ToolExecutor
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
InputContentConversionFunction = Callable[
|
|
20
|
+
[Iterable[ExtendedInputContentPart]], Awaitable[Iterable[InputContentPart]]
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Agent(Protocol):
|
|
25
|
+
async def run(
|
|
26
|
+
self,
|
|
27
|
+
user_input: Iterable[InputContentPart],
|
|
28
|
+
on_chunk: ChunkCallback | None = None,
|
|
29
|
+
on_tool_result: ToolResultCallback | None = None,
|
|
30
|
+
) -> str: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class OpenAIAgent:
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
llm_provider: OpenAILLMProvider,
|
|
37
|
+
tools: list[Tool] | None = None,
|
|
38
|
+
max_iterations: int = 10,
|
|
39
|
+
memory: Memory | None = None,
|
|
40
|
+
tool_executor: ToolExecutor | None = None,
|
|
41
|
+
on_tool_result: ToolResultCallback | None = None,
|
|
42
|
+
):
|
|
43
|
+
self._llm_provider = llm_provider
|
|
44
|
+
self._tools: dict[str, Tool] = {t.name: t for t in (tools or [])}
|
|
45
|
+
self._tool_executor = tool_executor or ToolExecutor(self._tools, on_tool_result)
|
|
46
|
+
self._max_iterations = max_iterations
|
|
47
|
+
self._memory = memory or ConversationMemory()
|
|
48
|
+
|
|
49
|
+
async def run(
|
|
50
|
+
self,
|
|
51
|
+
user_input: Iterable[ExtendedInputContentPart],
|
|
52
|
+
custom_input_conversion: InputContentConversionFunction | None = None,
|
|
53
|
+
on_chunk: ChunkCallback[ChatCompletionChunk] | None = None,
|
|
54
|
+
on_tool_result: ToolResultCallback | None = None,
|
|
55
|
+
) -> str:
|
|
56
|
+
if on_tool_result:
|
|
57
|
+
self._tool_executor._on_tool_result = on_tool_result
|
|
58
|
+
|
|
59
|
+
converted_user_input = user_input
|
|
60
|
+
if custom_input_conversion:
|
|
61
|
+
converted_user_input = await custom_input_conversion(converted_user_input)
|
|
62
|
+
self._memory.add_message(
|
|
63
|
+
cast(UserMessage, {"role": "user", "content": converted_user_input})
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
for _ in range(self._max_iterations):
|
|
67
|
+
response = await self._llm_provider.chat(
|
|
68
|
+
messages=self._memory.get_all_messages(),
|
|
69
|
+
tools=list(self._tools.values()),
|
|
70
|
+
on_chunk=on_chunk,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
async for _ in response:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
llm_response = response.response
|
|
77
|
+
self._memory.add_message(
|
|
78
|
+
cast(
|
|
79
|
+
Message,
|
|
80
|
+
{
|
|
81
|
+
"role": "assistant",
|
|
82
|
+
"content": llm_response.content,
|
|
83
|
+
"tool_calls": llm_response.tool_calls or None,
|
|
84
|
+
},
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
if llm_response.usage:
|
|
89
|
+
self._memory.add_usage(llm_response.usage)
|
|
90
|
+
|
|
91
|
+
if not llm_response.tool_calls:
|
|
92
|
+
return str(llm_response.content) or ""
|
|
93
|
+
|
|
94
|
+
results = await self._tool_executor.execute(llm_response.tool_calls)
|
|
95
|
+
for msg in results:
|
|
96
|
+
self._memory.add_message(msg)
|
|
97
|
+
|
|
98
|
+
raise RuntimeError(
|
|
99
|
+
f"Agent exceeded maximum iterations ({self._max_iterations})"
|
|
100
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from .llm import (
|
|
2
|
+
ChunkCallback,
|
|
3
|
+
LLMProvider,
|
|
4
|
+
LLMResponse,
|
|
5
|
+
Stream,
|
|
6
|
+
TokenUsage,
|
|
7
|
+
ToolCall,
|
|
8
|
+
ToolCallFunction,
|
|
9
|
+
ToolResultCallback,
|
|
10
|
+
)
|
|
11
|
+
from .openai import OpenAILLMProvider
|
|
12
|
+
|
|
13
|
+
__ALL__ = [
|
|
14
|
+
ChunkCallback,
|
|
15
|
+
LLMProvider,
|
|
16
|
+
LLMResponse,
|
|
17
|
+
Stream,
|
|
18
|
+
TokenUsage,
|
|
19
|
+
ToolCall,
|
|
20
|
+
ToolCallFunction,
|
|
21
|
+
ToolResultCallback,
|
|
22
|
+
OpenAILLMProvider,
|
|
23
|
+
]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from typing import Protocol, AsyncIterator, Any, Callable, Awaitable, TypeVar, TypedDict
|
|
2
|
+
|
|
3
|
+
from minimal_harness.memory import Message
|
|
4
|
+
from minimal_harness.tool import Tool
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
T = TypeVar("T")
|
|
8
|
+
|
|
9
|
+
ChunkCallback = Callable[[T, bool], Awaitable[None]]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ToolCallFunction(TypedDict):
|
|
13
|
+
name: str
|
|
14
|
+
arguments: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ToolCall(TypedDict):
|
|
18
|
+
id: str
|
|
19
|
+
type: str
|
|
20
|
+
function: ToolCallFunction
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
ToolResultCallback = Callable[[ToolCall, Any], Awaitable[None]]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TokenUsage(TypedDict):
|
|
27
|
+
prompt_tokens: int
|
|
28
|
+
completion_tokens: int
|
|
29
|
+
total_tokens: int
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class LLMResponse:
|
|
33
|
+
content: str | None
|
|
34
|
+
tool_calls: list[ToolCall]
|
|
35
|
+
finish_reason: str | None
|
|
36
|
+
usage: TokenUsage | None
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
content: str | None,
|
|
41
|
+
tool_calls: list[ToolCall],
|
|
42
|
+
finish_reason: str | None,
|
|
43
|
+
usage: TokenUsage | None = None,
|
|
44
|
+
):
|
|
45
|
+
self.content = content
|
|
46
|
+
self.tool_calls = tool_calls
|
|
47
|
+
self.finish_reason = finish_reason
|
|
48
|
+
self.usage = usage
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Stream[T]:
|
|
52
|
+
def __init__(self, agen: AsyncIterator[T]):
|
|
53
|
+
self._agen = agen
|
|
54
|
+
self._response: LLMResponse | None = None
|
|
55
|
+
|
|
56
|
+
def __aiter__(self) -> AsyncIterator:
|
|
57
|
+
return self
|
|
58
|
+
|
|
59
|
+
async def __anext__(self) -> Any:
|
|
60
|
+
try:
|
|
61
|
+
chunk = await self._agen.__anext__()
|
|
62
|
+
|
|
63
|
+
if isinstance(chunk, LLMResponse):
|
|
64
|
+
self._response = chunk
|
|
65
|
+
raise StopAsyncIteration
|
|
66
|
+
|
|
67
|
+
return chunk
|
|
68
|
+
|
|
69
|
+
except StopAsyncIteration:
|
|
70
|
+
raise
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def response(self) -> LLMResponse:
|
|
74
|
+
if self._response is None:
|
|
75
|
+
raise RuntimeError("Stream not exhausted yet")
|
|
76
|
+
return self._response
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class LLMProvider(Protocol):
|
|
80
|
+
async def chat(
|
|
81
|
+
self,
|
|
82
|
+
messages: list[Message],
|
|
83
|
+
tools: list[Tool],
|
|
84
|
+
on_chunk: ChunkCallback | None,
|
|
85
|
+
) -> Stream: ...
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from typing import AsyncIterator
|
|
2
|
+
|
|
3
|
+
from openai import AsyncOpenAI
|
|
4
|
+
from openai.types.chat import ChatCompletionChunk
|
|
5
|
+
|
|
6
|
+
from minimal_harness.llm import (
|
|
7
|
+
ChunkCallback,
|
|
8
|
+
LLMResponse,
|
|
9
|
+
Stream,
|
|
10
|
+
TokenUsage,
|
|
11
|
+
ToolCall,
|
|
12
|
+
ToolCallFunction,
|
|
13
|
+
)
|
|
14
|
+
from minimal_harness.memory import Message
|
|
15
|
+
from minimal_harness.tool import Tool
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class OpenAILLMProvider:
|
|
19
|
+
def __init__(self, client: AsyncOpenAI, model: str = "qwen3.5-27b"):
|
|
20
|
+
self._client = client
|
|
21
|
+
self._model = model
|
|
22
|
+
|
|
23
|
+
async def chat(
|
|
24
|
+
self,
|
|
25
|
+
messages: list[Message],
|
|
26
|
+
tools: list[Tool],
|
|
27
|
+
on_chunk: ChunkCallback | None,
|
|
28
|
+
) -> Stream[ChatCompletionChunk | LLMResponse]:
|
|
29
|
+
agen = self._chat(messages, tools, on_chunk)
|
|
30
|
+
return Stream(agen)
|
|
31
|
+
|
|
32
|
+
async def _chat(
|
|
33
|
+
self,
|
|
34
|
+
messages: list[Message],
|
|
35
|
+
tools: list[Tool],
|
|
36
|
+
on_chunk: ChunkCallback | None,
|
|
37
|
+
) -> AsyncIterator[ChatCompletionChunk | LLMResponse]:
|
|
38
|
+
stream = await self._client.chat.completions.create(
|
|
39
|
+
model=self._model,
|
|
40
|
+
messages=messages, # type: ignore[arg-type]
|
|
41
|
+
tools=[t.to_schema() for t in tools],
|
|
42
|
+
tool_choice="auto" if tools else "none",
|
|
43
|
+
stream=True,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
content_parts = []
|
|
47
|
+
tool_calls_acc: dict[int, ToolCall] = {}
|
|
48
|
+
finish_reason = None
|
|
49
|
+
usage: TokenUsage | None = None
|
|
50
|
+
|
|
51
|
+
async for chunk in stream:
|
|
52
|
+
if on_chunk:
|
|
53
|
+
await on_chunk(chunk, False)
|
|
54
|
+
|
|
55
|
+
delta = chunk.choices[0].delta if chunk.choices else None
|
|
56
|
+
|
|
57
|
+
# there should only be one single chunk with usage in a request
|
|
58
|
+
if getattr(chunk, "usage") and chunk.usage:
|
|
59
|
+
usage = {
|
|
60
|
+
"prompt_tokens": chunk.usage.prompt_tokens,
|
|
61
|
+
"completion_tokens": chunk.usage.completion_tokens,
|
|
62
|
+
"total_tokens": chunk.usage.total_tokens,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if delta is None:
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
if delta.content:
|
|
69
|
+
content_parts.append(delta.content)
|
|
70
|
+
|
|
71
|
+
if delta.tool_calls:
|
|
72
|
+
for tc_delta in delta.tool_calls:
|
|
73
|
+
idx = tc_delta.index
|
|
74
|
+
if idx not in tool_calls_acc:
|
|
75
|
+
tool_calls_acc[idx] = ToolCall(
|
|
76
|
+
id="",
|
|
77
|
+
type="function",
|
|
78
|
+
function=ToolCallFunction(name="", arguments=""),
|
|
79
|
+
)
|
|
80
|
+
acc = tool_calls_acc[idx]
|
|
81
|
+
if tc_delta.id:
|
|
82
|
+
acc["id"] += tc_delta.id
|
|
83
|
+
if tc_delta.function:
|
|
84
|
+
if tc_delta.function.name:
|
|
85
|
+
acc["function"]["name"] += tc_delta.function.name
|
|
86
|
+
if tc_delta.function.arguments:
|
|
87
|
+
acc["function"]["arguments"] += tc_delta.function.arguments
|
|
88
|
+
|
|
89
|
+
if chunk.choices and chunk.choices[0].finish_reason:
|
|
90
|
+
finish_reason = chunk.choices[0].finish_reason
|
|
91
|
+
|
|
92
|
+
yield chunk
|
|
93
|
+
|
|
94
|
+
if on_chunk:
|
|
95
|
+
await on_chunk(None, True)
|
|
96
|
+
|
|
97
|
+
yield LLMResponse(
|
|
98
|
+
content="".join(content_parts) or None,
|
|
99
|
+
tool_calls=list(tool_calls_acc.values()) if tool_calls_acc else [],
|
|
100
|
+
finish_reason=finish_reason,
|
|
101
|
+
usage=usage,
|
|
102
|
+
)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from typing import Protocol, TypedDict, Literal, Any
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TextContentPart(TypedDict):
|
|
5
|
+
type: Literal["text"]
|
|
6
|
+
text: str
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FileMetadata(TypedDict):
|
|
10
|
+
file_id: str
|
|
11
|
+
file_name: str
|
|
12
|
+
file_size: int
|
|
13
|
+
backend_type: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FileContentPart(TypedDict):
|
|
17
|
+
type: Literal["file"]
|
|
18
|
+
file: FileMetadata
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
InputContentPart = TextContentPart
|
|
22
|
+
ExtendedInputContentPart = FileContentPart | TextContentPart
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SystemMessage(TypedDict):
|
|
26
|
+
role: Literal["system"]
|
|
27
|
+
content: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class UserMessage(TypedDict):
|
|
31
|
+
role: Literal["user"]
|
|
32
|
+
content: list[InputContentPart]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AssistantMessage(TypedDict):
|
|
36
|
+
role: Literal["assistant"]
|
|
37
|
+
content: str | None
|
|
38
|
+
tool_calls: list[Any] | None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ToolMessage(TypedDict):
|
|
42
|
+
role: Literal["tool"]
|
|
43
|
+
tool_call_id: str
|
|
44
|
+
content: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
Message = SystemMessage | UserMessage | AssistantMessage | ToolMessage
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TokenUsage(TypedDict):
|
|
51
|
+
prompt_tokens: int
|
|
52
|
+
completion_tokens: int
|
|
53
|
+
total_tokens: int
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Memory(Protocol):
|
|
57
|
+
def add_message(self, message: Message) -> None: ...
|
|
58
|
+
def get_all_messages(self) -> list[Message]: ...
|
|
59
|
+
def clear_messages(self) -> None: ...
|
|
60
|
+
def add_usage(self, usage: TokenUsage) -> None: ...
|
|
61
|
+
def get_total_usage(self) -> TokenUsage: ...
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ConversationMemory:
|
|
65
|
+
def __init__(self, system_prompt: str = "You are a helpful assistant."):
|
|
66
|
+
self._messages: list[Message] = [{"role": "system", "content": system_prompt}]
|
|
67
|
+
self._total_usage: TokenUsage = {
|
|
68
|
+
"prompt_tokens": 0,
|
|
69
|
+
"completion_tokens": 0,
|
|
70
|
+
"total_tokens": 0,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
def add_message(self, message: Message) -> None:
|
|
74
|
+
self._messages.append(message)
|
|
75
|
+
|
|
76
|
+
def get_all_messages(self) -> list[Message]:
|
|
77
|
+
return self._messages.copy()
|
|
78
|
+
|
|
79
|
+
def clear_messages(self) -> None:
|
|
80
|
+
self._messages.clear()
|
|
81
|
+
|
|
82
|
+
def add_usage(self, usage: TokenUsage) -> None:
|
|
83
|
+
self._total_usage["prompt_tokens"] += usage["prompt_tokens"]
|
|
84
|
+
self._total_usage["completion_tokens"] += usage["completion_tokens"]
|
|
85
|
+
self._total_usage["total_tokens"] += usage["total_tokens"]
|
|
86
|
+
|
|
87
|
+
def get_total_usage(self) -> TokenUsage:
|
|
88
|
+
return self._total_usage.copy()
|
|
File without changes
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from openai.types.chat import ChatCompletionToolUnionParam
|
|
2
|
+
from typing import Any, Callable, Awaitable
|
|
3
|
+
|
|
4
|
+
ToolFunction = Callable[..., Awaitable[Any]]
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Tool:
|
|
8
|
+
def __init__(self, name: str, description: str, parameters: dict, fn: ToolFunction):
|
|
9
|
+
self.name = name
|
|
10
|
+
self.description = description
|
|
11
|
+
self.parameters = parameters
|
|
12
|
+
self.fn = fn
|
|
13
|
+
|
|
14
|
+
def to_schema(self) -> ChatCompletionToolUnionParam:
|
|
15
|
+
return {
|
|
16
|
+
"type": "function",
|
|
17
|
+
"function": {
|
|
18
|
+
"name": self.name,
|
|
19
|
+
"description": self.description,
|
|
20
|
+
"parameters": self.parameters,
|
|
21
|
+
},
|
|
22
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import fnmatch
|
|
2
|
+
|
|
3
|
+
from minimal_harness.tool.base import Tool
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
async def glob_handler(path: str = ".", pattern: str = "*") -> list[str]:
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
matched = []
|
|
10
|
+
for root, dirs, files in os.walk(path):
|
|
11
|
+
for name in files:
|
|
12
|
+
if fnmatch.fnmatch(name, pattern):
|
|
13
|
+
matched.append(os.path.join(root, name))
|
|
14
|
+
return matched
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
glob_tool = Tool(
|
|
18
|
+
name="glob",
|
|
19
|
+
description="Fast file pattern matching tool that works with any codebase size. Use this to find files by name patterns.",
|
|
20
|
+
parameters={
|
|
21
|
+
"type": "object",
|
|
22
|
+
"properties": {
|
|
23
|
+
"path": {
|
|
24
|
+
"type": "string",
|
|
25
|
+
"description": "The directory to search in. Defaults to the current working directory.",
|
|
26
|
+
},
|
|
27
|
+
"pattern": {
|
|
28
|
+
"type": "string",
|
|
29
|
+
"description": "The glob pattern to match files against (e.g., '*.js', 'src/**/*.ts').",
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
"required": ["pattern"],
|
|
33
|
+
},
|
|
34
|
+
fn=glob_handler,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_tools() -> dict[str, Tool]:
|
|
39
|
+
return {"glob": glob_tool}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import fnmatch
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
from minimal_harness.tool.base import Tool
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
async def grep_handler(
|
|
8
|
+
pattern: str,
|
|
9
|
+
include: str | None = None,
|
|
10
|
+
path: str | None = None,
|
|
11
|
+
) -> list[dict]:
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
results = []
|
|
15
|
+
target_path = path or "."
|
|
16
|
+
|
|
17
|
+
if include:
|
|
18
|
+
include_patterns = [p.strip() for p in include.split(",")]
|
|
19
|
+
else:
|
|
20
|
+
include_patterns = None
|
|
21
|
+
|
|
22
|
+
for root, dirs, files in os.walk(target_path):
|
|
23
|
+
for filename in files:
|
|
24
|
+
if include_patterns:
|
|
25
|
+
if not any(fnmatch.fnmatch(filename, p) for p in include_patterns):
|
|
26
|
+
continue
|
|
27
|
+
|
|
28
|
+
filepath = os.path.join(root, filename)
|
|
29
|
+
try:
|
|
30
|
+
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
|
|
31
|
+
for line_num, line in enumerate(f, 1):
|
|
32
|
+
if re.search(pattern, line):
|
|
33
|
+
results.append(
|
|
34
|
+
{
|
|
35
|
+
"file": filepath,
|
|
36
|
+
"line": line_num,
|
|
37
|
+
"content": line.rstrip(),
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
except (UnicodeDecodeError, IOError):
|
|
41
|
+
continue
|
|
42
|
+
|
|
43
|
+
return results
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
grep_tool = Tool(
|
|
47
|
+
name="grep",
|
|
48
|
+
description="Fast content search tool that works with any codebase size. Searches file contents using regular expressions.",
|
|
49
|
+
parameters={
|
|
50
|
+
"type": "object",
|
|
51
|
+
"properties": {
|
|
52
|
+
"pattern": {
|
|
53
|
+
"type": "string",
|
|
54
|
+
"description": "The regex pattern to search for in file contents",
|
|
55
|
+
},
|
|
56
|
+
"include": {
|
|
57
|
+
"type": "string",
|
|
58
|
+
"description": "File pattern to include in the search (e.g., '*.js', '*.{ts,tsx}')",
|
|
59
|
+
},
|
|
60
|
+
"path": {
|
|
61
|
+
"type": "string",
|
|
62
|
+
"description": "The directory to search in. Defaults to the current working directory.",
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
"required": ["pattern"],
|
|
66
|
+
},
|
|
67
|
+
fn=grep_handler,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_tools() -> dict[str, Tool]:
|
|
72
|
+
return {"grep": grep_tool}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import asyncio
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from minimal_harness.llm import ToolCall, ToolResultCallback
|
|
6
|
+
from minimal_harness.memory import Message
|
|
7
|
+
from minimal_harness.tool import Tool
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ToolExecutor:
|
|
11
|
+
def __init__(
|
|
12
|
+
self, tools: dict[str, Tool], on_tool_result: ToolResultCallback | None = None
|
|
13
|
+
):
|
|
14
|
+
self._tools = tools
|
|
15
|
+
self._on_tool_result = on_tool_result
|
|
16
|
+
|
|
17
|
+
async def execute(self, tool_calls: list[ToolCall]) -> list[Message]:
|
|
18
|
+
tasks = [self._execute_single(tc) for tc in tool_calls]
|
|
19
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
20
|
+
|
|
21
|
+
messages: list[Message] = []
|
|
22
|
+
for tc, result in zip(tool_calls, results):
|
|
23
|
+
if self._on_tool_result:
|
|
24
|
+
await self._on_tool_result(tc, result)
|
|
25
|
+
|
|
26
|
+
if isinstance(result, Exception):
|
|
27
|
+
content = f"[Tool Error] {tc['function']['name']}: {result}"
|
|
28
|
+
else:
|
|
29
|
+
content = (
|
|
30
|
+
json.dumps(result, ensure_ascii=False)
|
|
31
|
+
if not isinstance(result, str)
|
|
32
|
+
else result
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
messages.append(
|
|
36
|
+
{
|
|
37
|
+
"role": "tool",
|
|
38
|
+
"tool_call_id": tc["id"],
|
|
39
|
+
"content": content,
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
return messages
|
|
44
|
+
|
|
45
|
+
async def _execute_single(self, tc: ToolCall) -> Any:
|
|
46
|
+
name = tc["function"]["name"]
|
|
47
|
+
raw_args = tc["function"]["arguments"]
|
|
48
|
+
|
|
49
|
+
if name not in self._tools:
|
|
50
|
+
raise ValueError(f"Unknown tool: {name}")
|
|
51
|
+
|
|
52
|
+
args = json.loads(raw_args) if raw_args else {}
|
|
53
|
+
print(f"[Tool Call] {name}({args})")
|
|
54
|
+
return await self._tools[name].fn(**args)
|