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.
@@ -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,3 @@
1
+ from minimal_harness.tool.base import Tool, ToolFunction
2
+
3
+ __all__ = ["Tool", "ToolFunction"]
@@ -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)