Skip to content

Commit 6834a47

Browse files
authored
Merge pull request #18 from John-Lin/refactor/extract-instructions-file
Refactor/extract instructions file
2 parents 9013595 + b636dbb commit 6834a47

7 files changed

Lines changed: 75 additions & 40 deletions

File tree

.dockerignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@ __pycache__
88
firecrawl_selfhost_servers_config.json
99
jira_servers_config.json
1010
servers_config.json
11+
instructions.md
1112
.envrc
1213
.pre-commit-config.yaml

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,6 @@ wheels/
1010
.venv
1111
.envrc
1212
servers_config.json
13+
instructions.md
1314
access.json
1415
.access.pending.json

README.md

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,17 @@ export OPENAI_API_KEY=""
4343
export OPENAI_MODEL="gpt-5.4"
4444
```
4545

46+
## Agent Instructions
47+
48+
The bot loads its system prompt from `instructions.md` in the project root.
49+
If the file is missing, the bot fails fast at startup.
50+
51+
You can copy `instructions.md.example` as a starting point:
52+
53+
```bash
54+
cp instructions.md.example instructions.md
55+
```
56+
4657
If you are using Azure OpenAI (v1 API), set these instead:
4758

4859
```
@@ -57,7 +68,6 @@ Create a `servers_config.json` file to add your MCP servers. If this file is not
5768

5869
```json
5970
{
60-
"instructions": "Your custom system prompt here.",
6171
"mcpServers": {
6272
"my-server": {
6373
"command": "uvx",
@@ -67,13 +77,13 @@ Create a `servers_config.json` file to add your MCP servers. If this file is not
6777
}
6878
```
6979

70-
For HTTP-based MCP servers (Streamable HTTP), use `httpUrl`:
80+
For HTTP-based MCP servers (Streamable HTTP), use `url`:
7181

7282
```json
7383
{
7484
"mcpServers": {
7585
"my-server": {
76-
"httpUrl": "https://mcp.example.com/mcp",
86+
"url": "https://mcp.example.com/mcp",
7787
"headers": {
7888
"Accept": "application/json, text/event-stream"
7989
}
@@ -86,7 +96,6 @@ For local MCP servers, use `uv --directory`:
8696

8797
```json
8898
{
89-
"instructions": "Your custom system prompt here.",
9099
"mcpServers": {
91100
"my-server": {
92101
"command": "uv",
@@ -184,7 +193,22 @@ docker run -d \
184193
-e TELEGRAM_BOT_TOKEN="" \
185194
-e OPENAI_API_KEY="" \
186195
-e OPENAI_MODEL="gpt-5.4" \
196+
-v /path/to/instructions.md:/app/instructions.md \
187197
-v /path/to/servers_config.json:/app/servers_config.json \
188198
-v /path/to/access.json:/app/access.json \
189199
agentic-telegram-bot
190200
```
201+
202+
If you do not use MCP servers, you still need to mount `instructions.md`:
203+
204+
```bash
205+
docker run -d \
206+
--name telegent \
207+
-e BOT_USERNAME="@your_bot_username" \
208+
-e TELEGRAM_BOT_TOKEN="" \
209+
-e OPENAI_API_KEY="" \
210+
-e OPENAI_MODEL="gpt-5.4" \
211+
-v /path/to/instructions.md:/app/instructions.md \
212+
-v /path/to/access.json:/app/access.json \
213+
agentic-telegram-bot
214+
```

bot/agents.py

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import asyncio
44
import logging
55
import os
6+
from pathlib import Path
67
from typing import Any
78

89
from agents import Agent
@@ -15,19 +16,29 @@
1516
from agents.tracing import set_tracing_disabled
1617
from openai import AsyncOpenAI
1718

18-
DEFAULT_INSTRUCTIONS = (
19-
"You are a helpful assistant in a Telegram chat. "
20-
"When referencing articles, websites, or resources, always include "
21-
"the URL as a markdown hyperlink, e.g. [title](https://example.com). "
22-
"Keep responses concise and well-structured for mobile reading."
23-
)
19+
INSTRUCTIONS_FILE = Path("instructions.md")
2420

2521
MAX_TURNS = 10
2622
MCP_SESSION_TIMEOUT_SECONDS = 30.0
2723

2824
set_tracing_disabled(True)
2925

3026

27+
def _load_instructions() -> str:
28+
"""Load agent instructions from ``instructions.md`` in the working directory.
29+
30+
Fails fast with a clear error if the file is missing, so misconfiguration
31+
is caught immediately at startup.
32+
"""
33+
try:
34+
return INSTRUCTIONS_FILE.read_text(encoding="utf-8")
35+
except FileNotFoundError as e:
36+
raise FileNotFoundError(
37+
f"Instructions file not found: {INSTRUCTIONS_FILE.resolve()}. "
38+
"Create or mount instructions.md with the agent system prompt."
39+
) from e
40+
41+
3142
def _get_model() -> OpenAIResponsesModel | OpenAIChatCompletionsModel:
3243
"""Create an OpenAI model from environment variables.
3344
@@ -53,8 +64,8 @@ class OpenAIAgent:
5364
def __init__(
5465
self,
5566
name: str,
67+
instructions: str,
5668
mcp_servers: list | None = None,
57-
instructions: str = DEFAULT_INSTRUCTIONS,
5869
) -> None:
5970
self.agent = Agent(
6071
name=name,
@@ -116,8 +127,8 @@ def from_dict(cls, name: str, config: dict[str, Any]) -> OpenAIAgent:
116127
},
117128
)
118129
)
119-
instructions = config.get("instructions", DEFAULT_INSTRUCTIONS)
120-
return cls(name, mcp_servers, instructions=instructions)
130+
instructions = _load_instructions()
131+
return cls(name, instructions=instructions, mcp_servers=mcp_servers)
121132

122133
async def connect(self) -> None:
123134
for mcp_server in self.agent.mcp_servers:

instructions.md.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
You are a helpful assistant in a Telegram chat. When referencing articles, websites, or resources, always include the URL as a markdown hyperlink, e.g. [title](https://example.com). Keep responses concise and well-structured for mobile reading.

servers_config.example.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
{
2-
"instructions": "You are a helpful financial assistant. Help users look up stock data, news, and market information. Always include ticker symbols. Respond in the user's language. Keep responses concise for mobile reading. Do not offer follow-up suggestions or numbered options after answering.",
32
"mcpServers": {
43
"yfmcp": {
54
"command": "uvx",

tests/test_agents.py

Lines changed: 24 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,17 @@
1111
from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel
1212
from agents.models.openai_responses import OpenAIResponsesModel
1313

14-
from bot.agents import DEFAULT_INSTRUCTIONS
1514
from bot.agents import MAX_TURNS
1615
from bot.agents import OpenAIAgent
1716
from bot.agents import _get_model
1817

1918

19+
@pytest.fixture
20+
def _stub_instructions(monkeypatch):
21+
"""Stub out instructions.md loading for from_dict tests."""
22+
monkeypatch.setattr("bot.agents._load_instructions", lambda: "stub instructions")
23+
24+
2025
@pytest.fixture(autouse=True)
2126
def _mock_model(monkeypatch):
2227
"""Prevent tests from constructing a real OpenAI client."""
@@ -49,7 +54,7 @@ def test_returns_chat_completions_model_when_api_type_is_chat_completions(self,
4954
class TestPerChatConversations:
5055
def test_separate_chats_have_independent_history(self):
5156
"""Different chat_ids should maintain separate message histories."""
52-
agent = OpenAIAgent(name="test")
57+
agent = OpenAIAgent(name="test", instructions="test-prompt")
5358
agent.append_user_message(chat_id=100, message="hello from chat 100")
5459
agent.append_user_message(chat_id=200, message="hello from chat 200")
5560

@@ -62,7 +67,7 @@ def test_separate_chats_have_independent_history(self):
6267
assert msgs_200[0]["content"] == "hello from chat 200"
6368

6469
def test_same_chat_accumulates_messages(self):
65-
agent = OpenAIAgent(name="test")
70+
agent = OpenAIAgent(name="test", instructions="test-prompt")
6671
agent.append_user_message(chat_id=100, message="first")
6772
agent.append_user_message(chat_id=100, message="second")
6873

@@ -72,18 +77,18 @@ def test_same_chat_accumulates_messages(self):
7277
assert msgs[1]["content"] == "second"
7378

7479
def test_unknown_chat_returns_empty(self):
75-
agent = OpenAIAgent(name="test")
80+
agent = OpenAIAgent(name="test", instructions="test-prompt")
7681
assert agent.get_messages(chat_id=999) == []
7782

7883
def test_set_messages_replaces_history(self):
79-
agent = OpenAIAgent(name="test")
84+
agent = OpenAIAgent(name="test", instructions="test-prompt")
8085
agent.append_user_message(chat_id=100, message="old")
8186
new_msgs = [{"role": "user", "content": "replaced"}]
8287
agent.set_messages(chat_id=100, messages=new_msgs)
8388
assert agent.get_messages(chat_id=100) == new_msgs
8489

8590
def test_set_messages_does_not_affect_other_chats(self):
86-
agent = OpenAIAgent(name="test")
91+
agent = OpenAIAgent(name="test", instructions="test-prompt")
8792
agent.append_user_message(chat_id=100, message="chat 100")
8893
agent.append_user_message(chat_id=200, message="chat 200")
8994
agent.set_messages(chat_id=100, messages=[])
@@ -92,30 +97,23 @@ def test_set_messages_does_not_affect_other_chats(self):
9297

9398

9499
class TestInstructions:
95-
def test_default_instructions_when_none_provided(self):
96-
agent = OpenAIAgent(name="test")
97-
assert agent.agent.instructions == DEFAULT_INSTRUCTIONS
98-
99100
def test_custom_instructions(self):
100101
agent = OpenAIAgent(name="test", instructions="Be a HN bot.")
101102
assert agent.agent.instructions == "Be a HN bot."
102103

103-
def test_from_dict_reads_instructions(self):
104-
config = {
105-
"instructions": "Custom prompt here.",
106-
"mcpServers": {},
107-
}
108-
agent = OpenAIAgent.from_dict("test", config)
109-
assert agent.agent.instructions == "Custom prompt here."
104+
def test_from_dict_loads_instructions_from_file(self, tmp_path, monkeypatch):
105+
monkeypatch.chdir(tmp_path)
106+
(tmp_path / "instructions.md").write_text("From file prompt.", encoding="utf-8")
107+
agent = OpenAIAgent.from_dict("test", {"mcpServers": {}})
108+
assert agent.agent.instructions == "From file prompt."
110109

111-
def test_from_dict_uses_default_without_instructions(self):
112-
config = {
113-
"mcpServers": {},
114-
}
115-
agent = OpenAIAgent.from_dict("test", config)
116-
assert agent.agent.instructions == DEFAULT_INSTRUCTIONS
110+
def test_from_dict_fails_fast_when_instructions_file_missing(self, tmp_path, monkeypatch):
111+
monkeypatch.chdir(tmp_path)
112+
with pytest.raises(FileNotFoundError, match="Instructions file not found"):
113+
OpenAIAgent.from_dict("test", {"mcpServers": {}})
117114

118115

116+
@pytest.mark.usefixtures("_stub_instructions")
119117
class TestFromDictMcpServers:
120118
def test_url_creates_streamable_http_server(self):
121119
config = {
@@ -172,7 +170,7 @@ def test_default_max_turns(self):
172170
assert MAX_TURNS == 10
173171

174172
def test_truncate_keeps_recent_turns(self):
175-
agent = OpenAIAgent(name="test")
173+
agent = OpenAIAgent(name="test", instructions="test-prompt")
176174
# Simulate 30 turns: each turn is a user msg + assistant msg
177175
for i in range(30):
178176
agent.set_messages(
@@ -196,7 +194,7 @@ def test_truncate_keeps_recent_turns(self):
196194
assert user_msgs[-1]["content"] == "user-29"
197195

198196
def test_truncate_preserves_tool_messages_within_turn(self):
199-
agent = OpenAIAgent(name="test")
197+
agent = OpenAIAgent(name="test", instructions="test-prompt")
200198
# Build history with tool calls in a turn
201199
history = []
202200
for i in range(MAX_TURNS + 2):
@@ -218,7 +216,7 @@ def test_truncate_preserves_tool_messages_within_turn(self):
218216
assert len(tool_msgs) == 1
219217

220218
def test_no_truncation_when_under_limit(self):
221-
agent = OpenAIAgent(name="test")
219+
agent = OpenAIAgent(name="test", instructions="test-prompt")
222220
for i in range(3):
223221
agent.set_messages(
224222
chat_id=100,

0 commit comments

Comments
 (0)