minion-ai 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,11 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(pip install *)",
5
+ "Bash(python *)",
6
+ "Bash(.venv/Scripts/pip install *)",
7
+ "Bash(.venv/Scripts/python *)",
8
+ "WebFetch(domain:peps.python.org)"
9
+ ]
10
+ }
11
+ }
minion_ai-0.1.0/.env ADDED
@@ -0,0 +1,2 @@
1
+ GROQ_API_KEY_1=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
2
+ OPENAI_API_KEY=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
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: minion-ai
3
+ Version: 0.1.0
4
+ Summary: A simple agentic framework with observability and evals baked in
5
+ Project-URL: Repository, https://github.com/shriyansnaik/minions
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: docstring-parser
8
+ Requires-Dist: litellm
9
+ Requires-Dist: openai
10
+ Requires-Dist: pydantic
11
+ Description-Content-Type: text/markdown
12
+
13
+ # minions
14
+ A simple agentic framework - observability, evals baked in
@@ -0,0 +1,2 @@
1
+ # minions
2
+ A simple agentic framework - observability, evals baked in
@@ -0,0 +1,77 @@
1
+ # Minions — TODO
2
+
3
+ ---
4
+
5
+ ## 1. Provider Agnostic (LiteLLM)
6
+
7
+ **Problem:** Right now the code calls `client.responses.parse(...)` which is OpenAI's Responses API.
8
+ Other providers (Anthropic, Gemini, Mistral, etc.) do not have this API, so swapping the provider is not just swapping the key — it requires changing the call itself.
9
+
10
+ **Solution:** Use [LiteLLM](https://github.com/BerriAI/litellm) as an abstraction layer.
11
+ LiteLLM wraps every major provider behind one unified interface.
12
+
13
+ **What needs to change:**
14
+ - Replace `client.responses.parse(...)` with a LiteLLM call
15
+ - LiteLLM has structured output support via `response_format` — use that instead of `text_format`
16
+ - `minions.init()` would accept `provider` or just let the model string carry it (LiteLLM style: `"anthropic/claude-opus-4"`, `"gemini/gemini-2.5-pro"`)
17
+ - Drop the custom `get_client()` — LiteLLM handles the client internally
18
+
19
+ **New init signature:**
20
+ ```python
21
+ minions.init(api_key="...", model_prefix="anthropic")
22
+ # or just pass model strings like "anthropic/claude-opus-4" to Minion directly
23
+ ```
24
+
25
+ ---
26
+
27
+ ## 2. Observability UI (minions-ui)
28
+
29
+ A separate open source Docker app that shows traces from all Minion runs.
30
+
31
+ ### Architecture
32
+ ```
33
+ FastAPI backend ← Minion posts traces here after each run
34
+ SQLite database ← FastAPI stores traces as JSON
35
+ React frontend ← reads from FastAPI, renders trace tree
36
+ ```
37
+
38
+ ### How users run it
39
+ ```bash
40
+ git clone https://github.com/you/minions-ui
41
+ cd minions-ui
42
+ docker compose up
43
+ # UI available at http://localhost:7337
44
+ ```
45
+
46
+ ### How users enable tracing in their code
47
+ ```python
48
+ import minions
49
+ minions.init(api_key="...", tracing=True, ui_url="http://localhost:7337")
50
+ ```
51
+
52
+ ### What the UI shows
53
+ - List of all runs (left panel) — timestamp, model, first user message
54
+ - Trace tree (right panel) for selected run:
55
+ - User input
56
+ - Each turn: thought → tool calls (with args) → tool outputs
57
+ - Sub-minion runs nested under the parent
58
+ - Final answer
59
+ - Token usage + latency per turn
60
+
61
+ ### What needs to be built
62
+ - [ ] `Minion.to_trace()` — serializes conversation + raw_model_responses to a JSON blob
63
+ - [ ] POST to `ui_url/api/traces` after `_finish` is called
64
+ - [ ] FastAPI app with two endpoints: `POST /api/traces`, `GET /api/traces`, `GET /api/traces/{id}`
65
+ - [ ] SQLite schema: `id`, `timestamp`, `model`, `input`, `trace_json`
66
+ - [ ] React app (Vite + React, no UI kit) with run list + trace tree
67
+ - [ ] Dockerfile + docker-compose.yml
68
+
69
+ ---
70
+
71
+ ## 3. Package Setup (pip installable)
72
+
73
+ - [ ] Add `pyproject.toml` (or `setup.py`)
74
+ - [ ] Decide package name (e.g. `minions-ai`)
75
+ - [ ] Publish to PyPI
76
+ - [ ] `minions[ui]` optional dependency group for FastAPI + uvicorn
77
+ - [ ] `minions ui` CLI command to start the UI server locally without Docker
@@ -0,0 +1,4 @@
1
+ from .config import init
2
+ from .minion import Minion
3
+
4
+ __all__ = ["Minion", "init"]
@@ -0,0 +1,16 @@
1
+ from openai import OpenAI
2
+ from .config import get_config
3
+
4
+
5
+ def get_client() -> OpenAI:
6
+ config = get_config()
7
+
8
+ if not config.api_key:
9
+ raise RuntimeError(
10
+ "No API key set. Call minions.init(api_key='...') before using Minion."
11
+ )
12
+
13
+ return OpenAI(
14
+ api_key=config.api_key,
15
+ base_url=config.base_url,
16
+ )
@@ -0,0 +1,37 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Optional
3
+
4
+
5
+ @dataclass
6
+ class _Config:
7
+ api_key: Optional[str] = None
8
+ base_url: Optional[str] = None
9
+ tracing: bool = False
10
+ ui_url: Optional[str] = None
11
+
12
+
13
+ _config = _Config()
14
+
15
+
16
+ def init(
17
+ api_key: str,
18
+ base_url: str = None,
19
+ tracing: bool = False,
20
+ ui_url: str = None,
21
+ ):
22
+ """Configure the minions library. Call once before creating any Minion.
23
+
24
+ Args:
25
+ api_key: Your OpenAI API key.
26
+ base_url: Optional custom endpoint (Azure, vLLM, any OpenAI-compatible API).
27
+ tracing: Enable trace collection.
28
+ ui_url: URL of the minions-ui server (required if tracing=True).
29
+ """
30
+ _config.api_key = api_key
31
+ _config.base_url = base_url
32
+ _config.tracing = tracing
33
+ _config.ui_url = ui_url
34
+
35
+
36
+ def get_config() -> _Config:
37
+ return _config
@@ -0,0 +1,204 @@
1
+ import inspect
2
+ import docstring_parser
3
+ from typing import Callable, Literal
4
+
5
+ from .models import Tool, ToolArg, ToolCall, MinionOutput
6
+ from .client import get_client
7
+
8
+ MINION_BASE_PROMPT = """#Role
9
+ You are Minion, a powerful AI agent. Given user input, produce the best possible output using your available tools.
10
+
11
+ ====================================================
12
+ # Tools You Have
13
+
14
+ {tool_schemas}
15
+ ====================================================
16
+
17
+ ## Sub Minions (Delegating huge tasks)
18
+ If the above tools include `_spawn_sub_minion`, use it to delegate large tasks.
19
+
20
+ **Trigger rules (apply these before starting work):**
21
+ - Task involves reading or processing **3+ independent files/URLs/items** → delegate to sub minions
22
+ - Split items evenly: e.g. 12 files → 3 sub minions × 4 files each
23
+ - Each sub minion gets: the same instructions + its specific subset of items
24
+ - You synthesize their results; you do NOT read the files yourself
25
+
26
+ Only skip delegation if items are fewer than 3, or one item's output feeds another.
27
+
28
+ ## Thoughts
29
+ Keep thoughts concise — enough for the human to follow your reasoning. Do not name tools directly; describe your intent instead.
30
+
31
+ ## Tool Arguments
32
+ All tool args must be valid JSON. Escape double quotes where needed.
33
+
34
+ ## Multiple Tools
35
+ Call independent tools simultaneously; only sequence tools when one depends on another's output.
36
+
37
+ ## Output Format
38
+ Every response must include `next_thought` and `next_tools`.
39
+ When you have sufficient information to answer, call `_finish` with your answer in `final_response`."""
40
+
41
+
42
+ class Minion:
43
+
44
+ def __init__(
45
+ self,
46
+ model: str,
47
+ reasoning_effort: str = None,
48
+ secondary_model: str = None,
49
+ secondary_model_reasoning_effort: str = None,
50
+ system_prompt: str = None,
51
+ tools: list | None = [],
52
+ allow_sub_agents: bool = False,
53
+ max_turns: int = 10,
54
+ ):
55
+ self.model = model
56
+ self.reasoning_effort = reasoning_effort
57
+ self.secondary_model = secondary_model
58
+ self.secondary_model_reasoning_effort = secondary_model_reasoning_effort
59
+ self.system_prompt = system_prompt
60
+ self.tools = tools
61
+ self.parsed_tools = [self._parse_tool(tool) for tool in tools]
62
+ self.allow_sub_agents = allow_sub_agents
63
+ self.max_turns = max_turns
64
+
65
+ self.raw_model_responses = []
66
+
67
+ if allow_sub_agents:
68
+ if not secondary_model:
69
+ print("No secondary model setup, sub agents will use the main model")
70
+ self.secondary_model = model
71
+ if not secondary_model_reasoning_effort:
72
+ print("No secondary model reasoning settings found, sub agents will use the main models reasoning settings")
73
+ self.secondary_model_reasoning_effort = reasoning_effort
74
+ self.parsed_tools.append(self._parse_tool(self._spawn_sub_minion))
75
+
76
+ self.parsed_tools.append(self._parse_tool(self._finish))
77
+
78
+ self.conversation = []
79
+ self.instructions = None
80
+
81
+ def _parse_tool(self, fn: Callable) -> Tool:
82
+ sig = inspect.signature(fn)
83
+ parsed_doc = docstring_parser.parse(inspect.getdoc(fn) or "")
84
+ param_descriptions = {p.arg_name: p.description for p in parsed_doc.params}
85
+
86
+ parameters = {}
87
+ for name, param in sig.parameters.items():
88
+ annotation = param.annotation
89
+ parameters[name] = {
90
+ "type": annotation.__name__ if annotation != inspect.Parameter.empty else "string",
91
+ "description": param_descriptions.get(name, "")
92
+ }
93
+
94
+ return Tool(
95
+ schema={
96
+ "tool_name": fn.__name__,
97
+ "description": parsed_doc.short_description or "",
98
+ "parameters": parameters,
99
+ },
100
+ fn=fn,
101
+ )
102
+
103
+ def _finish(self, final_response: str) -> str:
104
+ """Call this when you have completed the user's request.
105
+
106
+ Args:
107
+ final_response: The message shown directly to the user. Must be phrased as a direct answer or summary of the completed request.
108
+
109
+ Returns:
110
+ The final_response string, passed through unchanged.
111
+ """
112
+ return final_response
113
+
114
+ def _spawn_sub_minion(self, input: str, tool_list: list[str] = []) -> str:
115
+ """Spawn an independent sub-minion to complete a task and return its answer.
116
+
117
+ Use when a task is large, separable into parallel subtasks, or requires reading many files (eg. spawn 25 sub-minions each reading 4 files rather than reading 100 yourself — too much information can confuse you if read all at once).
118
+
119
+ The sub-minion has no conversation memory, so `input` must be fully self-contained: include all context, constraints, and instructions needed to complete the task from scratch.
120
+
121
+ Args:
122
+ input: Self-contained task description with all required context.
123
+ tool_list: Restrict to specific tools by passing the names to prevent sub minion to go rogue. If tool_list is not passed, sub minion will have all tools.
124
+
125
+ Returns:
126
+ The sub-minion's final answer.
127
+ """
128
+ if not tool_list:
129
+ tools = self.tools
130
+ else:
131
+ tools = [tool.fn for tool in self.parsed_tools if tool.schema["tool_name"] in tool_list]
132
+
133
+ sub_minion = Minion(
134
+ model=self.secondary_model,
135
+ reasoning_effort=self.secondary_model_reasoning_effort,
136
+ tools=tools,
137
+ allow_sub_agents=False,
138
+ )
139
+ print("=== CREATED A SUB MINION. RUNNING IT NOW ===")
140
+ sub_output = sub_minion(input)
141
+ print("=== SUB MINION HAS ENDED ===")
142
+ return sub_output
143
+
144
+ def _add_to_conversation(
145
+ self,
146
+ message: MinionOutput | str,
147
+ message_type: Literal["system", "user", "thought", "tool", "tool_output"] = None,
148
+ ):
149
+ if isinstance(message, MinionOutput):
150
+ self.conversation.append({"message_type": "thought", "content": message.next_thought})
151
+
152
+ for tool in message.next_tools:
153
+ args = ", ".join(f"{a.key}={a.value!r}" for a in tool.args)
154
+ self.conversation.append({
155
+ "message_type": "tool",
156
+ "content": f"Tool('{tool.tool_name}' called with Args({args}))",
157
+ })
158
+ else:
159
+ self.conversation.append({
160
+ "message_type": message_type or "tool_output",
161
+ "content": message,
162
+ })
163
+
164
+ def invoke_tool(self, tool_name: str, args: dict) -> str:
165
+ matches = [t for t in self.parsed_tools if t.schema["tool_name"] == tool_name]
166
+ if not matches:
167
+ raise ValueError(f"Tool '{tool_name}' not found")
168
+ return matches[0].fn(**args)
169
+
170
+ def __call__(self, input: str) -> str:
171
+ self._add_to_conversation(message=input, message_type="user")
172
+
173
+ tool_schemas = "\n".join([str(tool.schema) for tool in self.parsed_tools])
174
+ self.instructions = MINION_BASE_PROMPT.format(tool_schemas=tool_schemas)
175
+ if self.system_prompt:
176
+ self.instructions += f"\n\n## Special Instructions from User\n{self.system_prompt}"
177
+
178
+ client = get_client()
179
+
180
+ for _ in range(self.max_turns):
181
+ response = client.responses.parse(
182
+ model=self.model,
183
+ instructions=self.instructions,
184
+ reasoning={"effort": self.reasoning_effort, "summary": "detailed"},
185
+ input=str(self.conversation),
186
+ text_format=MinionOutput,
187
+ )
188
+ self.raw_model_responses.append(response)
189
+
190
+ output = response.output_parsed
191
+ print(output, end="\n\n")
192
+ self._add_to_conversation(message=output)
193
+
194
+ for tool in output.next_tools:
195
+ tool_output = self.invoke_tool(
196
+ tool_name=tool.tool_name,
197
+ args={a.key: a.value for a in tool.args},
198
+ )
199
+ self._add_to_conversation(message=tool_output)
200
+
201
+ if tool.tool_name == "_finish":
202
+ return tool_output
203
+
204
+ print(f"OOPS!! {self.max_turns} turns were not enough")
@@ -0,0 +1,34 @@
1
+ from dataclasses import dataclass
2
+ from typing import Callable
3
+ from pydantic import BaseModel
4
+
5
+
6
+ @dataclass
7
+ class Tool:
8
+ fn: Callable
9
+ schema: dict
10
+
11
+
12
+ class ToolArg(BaseModel):
13
+ key: str
14
+ value: str
15
+
16
+
17
+ class ToolCall(BaseModel):
18
+ model_config = {"extra": "forbid"}
19
+ tool_name: str
20
+ args: list[ToolArg]
21
+
22
+
23
+ class MinionOutput(BaseModel):
24
+ model_config = {"extra": "forbid"}
25
+ next_thought: str
26
+ next_tools: list[ToolCall]
27
+
28
+ def __str__(self) -> str:
29
+ thought = f"Thought: {self.next_thought!r}"
30
+ tools = "\n".join([
31
+ f"Tool_{i+1}: {tool.tool_name!r}\nArgs: {', '.join(f'{a.key}={a.value!r}' for a in tool.args)}"
32
+ for i, tool in enumerate(self.next_tools)
33
+ ])
34
+ return "\n".join([thought, tools])
@@ -0,0 +1,49 @@
1
+ import os
2
+
3
+
4
+ def read_file(file_path: str, encoding: str = "utf-8") -> str:
5
+ """Reads the contents of a file and returns them as a string.
6
+
7
+ Args:
8
+ file_path: Path to the file to be read.
9
+ encoding: Character encoding to use when decoding the file.
10
+ Defaults to 'utf-8'.
11
+
12
+ Returns:
13
+ The full contents of the file as a string.
14
+
15
+ Raises:
16
+ FileNotFoundError: If the file does not exist at the given path.
17
+ PermissionError: If the process lacks read access to the file.
18
+ UnicodeDecodeError: If the file cannot be decoded with the given encoding.
19
+ """
20
+ with open(file_path, "r", encoding=encoding) as f:
21
+ return f.read()
22
+
23
+
24
+ def list_files(directory: str) -> list[str]:
25
+ """Lists all file paths within a directory, excluding hidden and private entries.
26
+
27
+ Skips files and directories whose names start with '.' or '_'.
28
+
29
+ Args:
30
+ directory: Path to the directory to list files from.
31
+
32
+ Returns:
33
+ A list of absolute file paths found in the directory.
34
+
35
+ Raises:
36
+ FileNotFoundError: If the directory does not exist.
37
+ NotADirectoryError: If the given path is not a directory.
38
+ """
39
+ if not os.path.exists(directory):
40
+ raise FileNotFoundError(f"Directory not found: {directory}")
41
+ if not os.path.isdir(directory):
42
+ raise NotADirectoryError(f"Path is not a directory: {directory}")
43
+
44
+ return [
45
+ os.path.join(directory, entry)
46
+ for entry in os.listdir(directory)
47
+ if not entry.startswith((".", "_"))
48
+ and os.path.isfile(os.path.join(directory, entry))
49
+ ]
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "minion-ai"
7
+ version = "0.1.0"
8
+ description = "A simple agentic framework with observability and evals baked in"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ "openai",
13
+ "pydantic",
14
+ "docstring-parser",
15
+ "litellm",
16
+ ]
17
+
18
+ [tool.hatch.build.targets.wheel]
19
+ packages = ["minions"]
20
+
21
+ [project.urls]
22
+ Repository = "https://github.com/shriyansnaik/minions"
23
+
@@ -0,0 +1,4 @@
1
+ openai
2
+ pydantic
3
+ docstring-parser
4
+ litellm
@@ -0,0 +1,105 @@
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "code",
5
+ "execution_count": 1,
6
+ "id": "cc0fd4f3",
7
+ "metadata": {},
8
+ "outputs": [],
9
+ "source": [
10
+ "from minions import Minion\n",
11
+ "import minions as mn\n",
12
+ "from minions.tools import read_file, list_files"
13
+ ]
14
+ },
15
+ {
16
+ "cell_type": "code",
17
+ "execution_count": 2,
18
+ "id": "b64ef4e1",
19
+ "metadata": {},
20
+ "outputs": [],
21
+ "source": [
22
+ "OPENAI_API_KEY = \"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\""
23
+ ]
24
+ },
25
+ {
26
+ "cell_type": "code",
27
+ "execution_count": 3,
28
+ "id": "b7e044de",
29
+ "metadata": {},
30
+ "outputs": [],
31
+ "source": [
32
+ "mn.init(api_key=OPENAI_API_KEY)"
33
+ ]
34
+ },
35
+ {
36
+ "cell_type": "code",
37
+ "execution_count": 7,
38
+ "id": "c01b53a3",
39
+ "metadata": {},
40
+ "outputs": [],
41
+ "source": [
42
+ "minion = Minion(\n",
43
+ " model=\"gpt-5.1-chat-latest\",\n",
44
+ " tools=[read_file, list_files]\n",
45
+ ")"
46
+ ]
47
+ },
48
+ {
49
+ "cell_type": "code",
50
+ "execution_count": 8,
51
+ "id": "233d4568",
52
+ "metadata": {},
53
+ "outputs": [
54
+ {
55
+ "name": "stdout",
56
+ "output_type": "stream",
57
+ "text": [
58
+ "Thought: 'List directory to find requirements.txt.'\n",
59
+ "Tool_1: 'list_files'\n",
60
+ "Args: directory='.'\n",
61
+ "\n",
62
+ "Thought: 'No requirements file exists, so respond with that.'\n",
63
+ "Tool_1: '_finish'\n",
64
+ "Args: final_response='There is no requirements.txt file in the project directory.'\n",
65
+ "\n"
66
+ ]
67
+ }
68
+ ],
69
+ "source": [
70
+ "res = minion(\"can you check requirements.txt and tell me the requirements for the project\")"
71
+ ]
72
+ },
73
+ {
74
+ "cell_type": "code",
75
+ "execution_count": null,
76
+ "id": "dda435f9",
77
+ "metadata": {},
78
+ "outputs": [],
79
+ "source": [
80
+ " "
81
+ ]
82
+ }
83
+ ],
84
+ "metadata": {
85
+ "kernelspec": {
86
+ "display_name": ".venv (3.13.3.final.0)",
87
+ "language": "python",
88
+ "name": "python3"
89
+ },
90
+ "language_info": {
91
+ "codemirror_mode": {
92
+ "name": "ipython",
93
+ "version": 3
94
+ },
95
+ "file_extension": ".py",
96
+ "mimetype": "text/x-python",
97
+ "name": "python",
98
+ "nbconvert_exporter": "python",
99
+ "pygments_lexer": "ipython3",
100
+ "version": "3.13.3"
101
+ }
102
+ },
103
+ "nbformat": 4,
104
+ "nbformat_minor": 5
105
+ }