Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,14 @@ docker compose --env-file .env -f deploy/compose.yaml -f deploy/compose.hermes.y
```

`/healthz`는 브리지 프로세스 상태만 검사합니다. 인증과 모델 사용 가능 여부는 실제 요약 요청으로 검증해야 합니다. 요청이 실패해도 유료 OpenAI API로 자동 전환하지 않습니다.

## 일반 문서 질문의 조회 제한

`deploy/hermes/configure_wiki_budget.py`는 사용자 Hermes 홈에 `framework-wiki-budget` 플러그인을 설치하고 기존 설정을 비공개 백업합니다. 배치 후 Gateway를 재시작합니다. 플러그인은 Discord의 일반 Framework·InnoLive 문서 질문에만 적용됩니다. 심층 조사와 수정·배포 요청은 기존 경로를 사용합니다.

일반 질문은 문서 조회를 3회로 제한하고, 네 번째 모델 요청부터 도구 없이 답변합니다. 90초가 지나면 추가 조회를 중단합니다. 이미 진행 중인 모델·문서 요청은 이 제한으로 취소하지 않으므로 전체 응답 시간의 상한은 아닙니다. 과거 도구 출력은 요청에서 제외하고 최근 일반 대화는 8,000자 이내로 선택합니다. 이번 질문의 도구 호출과 결과 쌍, 시스템 지침, 저장된 대화 기록은 보존합니다. 기존 `gpt-6-luna`·`max` 추론 설정을 유지합니다.

```bash
python deploy/hermes/configure_wiki_budget.py --hermes-home /home/chaeyn/.hermes
python -m unittest discover -s deploy/hermes -p 'test_*.py'
```
35 changes: 35 additions & 0 deletions deploy/hermes/configure_wiki_budget.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Install the opt-in Wiki budget plugin without modifying Hermes core or secrets."""
import argparse
from datetime import datetime, timezone
from pathlib import Path
import shutil
import yaml

NAME = 'framework-wiki-budget'

def configure(home):
path = home / 'config.yaml'
config = yaml.safe_load(path.read_text())
plugins = config.setdefault('plugins', {})
if not isinstance(plugins, dict): raise ValueError('Review existing plugins settings')
enabled = plugins.get('enabled', [])
disabled = plugins.get('disabled', [])
if not isinstance(enabled,list) or not isinstance(disabled,list): raise ValueError('Review plugin allow-list')
plugins['enabled'] = list(dict.fromkeys([*enabled, NAME]))
if NAME in disabled: plugins['disabled'] = [name for name in disabled if name != NAME]
backup = home/'backups'/('wiki-budget-'+datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S%fZ'))
backup.mkdir(parents=True,mode=0o700)
shutil.copyfile(path,backup/'config.yaml');(backup/'config.yaml').chmod(0o600)
destination = home/'plugins'/NAME
if destination.exists(): shutil.copytree(destination,backup/NAME)
source=Path(__file__).with_name(NAME)
destination.mkdir(parents=True,exist_ok=True)
for name in ['plugin.yaml','__init__.py']:
temporary=destination/(name+'.tmp');temporary.write_bytes((source/name).read_bytes());temporary.replace(destination/name)
temporary=path.with_name('config.yaml.wiki-budget-tmp')
temporary.write_text(yaml.safe_dump(config,allow_unicode=True,sort_keys=False));temporary.chmod(0o600);temporary.replace(path)
return backup

if __name__=='__main__':
parser=argparse.ArgumentParser();parser.add_argument('--hermes-home',type=Path,default=Path('/home/chaeyn/.hermes'))
print('Wiki budget plugin installed; private backup:',configure(parser.parse_args().hermes_home))
126 changes: 126 additions & 0 deletions deploy/hermes/framework-wiki-budget/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""Request-only context selection and read budgets using Hermes plugin contracts."""
from copy import deepcopy
from dataclasses import dataclass, field
import logging
import re
import threading
import time

log = logging.getLogger(__name__)
TOPIC = re.compile(r'framework|innolive|inno live|이노라이브|위키|노션|notion', re.I)
LOOKUP = re.compile(r'알려|설명|요약|정리|찾아|변천|변화|현황|목록|링크|무엇|뭐|어떻|읽|볼 수|show|explain|summari|history|list|find', re.I)
DEEP_OR_WRITE = re.compile(r'심층|자세히|상세히|꼼꼼|전부|모든 문서|끝까지|deep research|exhaustive|코드|로그|실서버|운영 서버|GPU 서버|실제 실행|고쳐|수정해|수정해줘|작성해|구현해|코드.*(?:분석|확인|검토)|배포|커밋|실행해|갱신|업데이트해|삭제|등록해|보내|전송', re.I)
FOLLOWUP = re.compile(r'이 문서|그 문서|이 모델|그 모델|방금|앞서|이 중|그중|그러면|그럼|후속')
SOURCE = 'mcp__framework_wiki__'
READS = {'get_sources_context', 'search_notion', 'read_notion_page', 'search', 'get_context', 'get_note_outline', 'read_sections', 'get_wiki_status', 'list_notes'}
INSTRUCTION = '일반 문서 질문: get_sources_context(sources="all", limit=8, max_chars=12000)를 한 번 조회하고, 필요하면 원문을 최대 2회 보충한다. 제목 검색을 반복하지 않는다. 근거가 확보되면 원문 링크와 함께 답한다. 3회의 문서 조회 또는 3회의 도구 단계 뒤에는 조회를 끝내고 확인 범위와 남은 불확실성을 밝힌다. 요청하지 않은 심층 조사와 skill 수정을 시작하지 않는다.'


def text_of(message):
content = message.get('content', '')
if isinstance(content, str): return content
if isinstance(content, list):
return '\n'.join(x.get('text', '') for x in content if isinstance(x, dict) and isinstance(x.get('text'), str))
return ''


def ordinary_question(text, history=()):
if DEEP_OR_WRITE.search(text) or not LOOKUP.search(text): return False
if TOPIC.search(text): return True
return bool(FOLLOWUP.search(text) and any(TOPIC.search(text_of(m)) for m in [row for row in history if row.get('role') == 'user'][-3:]))


def select_messages(messages, old_chars=8000):
"""Keep the current user and all current tool pairs; select plain previous context."""
start = next((i for i in range(len(messages)-1, -1, -1) if messages[i].get('role') == 'user'), None)
if start is None: return deepcopy(messages)
prefix = [deepcopy(m) for m in messages[:start] if m.get('role') in ('system', 'developer')]
previous = []
for m in reversed(messages[:start]):
if m.get('role') not in ('user', 'assistant') or m.get('tool_calls') or m.get('type') == 'function_call': continue
content = text_of(m)
if not content: continue
budget = max(0, old_chars - sum(len(x['content']) for x in previous))
if not budget: break
cap = min(budget, 4000)
clipped = content if len(content) <= cap else content[:max(0, cap-1000)] + content[-min(1000, cap):]
previous.append({'role': m['role'], 'content': clipped})
if len(previous) >= 4: break
# Retain alternating plain conversation, without dangling old tool calls.
previous.reverse()
while previous and previous[-1]['role'] != 'assistant': previous.pop()
return prefix + previous + deepcopy(messages[start:])


@dataclass
class Turn:
started: float
reads: int = 0
catalog: int = 0
seen: set = field(default_factory=set)


class Budget:
def __init__(self, clock=time.monotonic):
self.clock = clock; self.turns = {}; self.lock = threading.RLock()

def key(self, session_id='', turn_id='', task_id='', **kwargs):
return (session_id, turn_id or task_id)

def begin(self, user_message='', conversation_history=(), platform='', **ids):
if platform != 'discord' or not ordinary_question(str(user_message), conversation_history): return None
with self.lock:
now = self.clock()
self.turns = {k: v for k, v in self.turns.items() if now-v.started < 600}
self.turns[self.key(**ids)] = Turn(now)
return {'context': INSTRUCTION}

def request(self, request, api_call_count=0, **ids):
with self.lock:
turn = self.turns.get(self.key(**ids))
if turn is None: return None
payload = deepcopy(request)
key = 'messages' if isinstance(payload.get('messages'), list) else 'input'
if isinstance(payload.get(key), list):
before = len(str(payload[key]))
payload[key] = select_messages(payload[key])
log.info('Wiki request context selected: chars=%s->%s reads=%s', before, len(str(payload[key])), turn.reads)
if turn.reads >= 3 or turn.catalog >= 8 or api_call_count >= 4 or self.clock()-turn.started >= 90:
payload['tools'] = []
payload.pop('tool_choice', None)
payload.pop('parallel_tool_calls', None)
return {'request': payload, 'source': 'framework-wiki-budget', 'reason': 'ordinary document question'}

def tool(self, tool_name, args, tool_call_id='', **ids):
with self.lock:
turn = self.turns.get(self.key(**ids))
if turn is None: return None
name = tool_name
if name == 'tool_call':
calls = args.get('calls', [])
if len(calls) != 1 or not isinstance(calls[0], dict): return self.block()
name = calls[0].get('name', '')
# The bridge may fire the hook again for the unwrapped name with the same id.
dedup = (tool_call_id, name)
if tool_call_id and dedup in turn.seen: return None
if self.clock()-turn.started >= 90: return self.block()
if name.startswith(SOURCE) and name[len(SOURCE):] in READS:
if turn.reads >= 3: return self.block()
turn.reads += 1
elif name in {'tool_search', 'tool_describe', 'skill_view', 'skills_list'}:
if turn.catalog >= 8: return self.block()
turn.catalog += 1
else: return self.block()
turn.seen.add(dedup)
return None

@staticmethod
def block():
return {'action': 'block', 'message': '일반 문서 질문의 조회 범위를 마쳤습니다. 확보한 근거로 답하고 확인하지 못한 부분을 밝혀 주세요.'}


def register(ctx):
budget = Budget()
ctx.register_hook('pre_llm_call', budget.begin)
ctx.register_hook('pre_tool_call', budget.tool)
ctx.register_middleware('llm_request', budget.request)
6 changes: 6 additions & 0 deletions deploy/hermes/framework-wiki-budget/plugin.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
name: framework-wiki-budget
version: 0.1.0
description: Bound ordinary Discord Wiki questions while preserving saved conversation history.
provides_hooks:
- pre_llm_call
- pre_tool_call
92 changes: 92 additions & 0 deletions deploy/hermes/test_wiki_budget.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import importlib.util
import sys
from pathlib import Path
import unittest

spec = importlib.util.spec_from_file_location('wiki_budget', Path(__file__).with_name('framework-wiki-budget')/'__init__.py')
m = importlib.util.module_from_spec(spec); sys.modules[spec.name] = m; spec.loader.exec_module(m)

class BudgetTests(unittest.TestCase):
def setUp(self):
self.now = 0
self.b = m.Budget(clock=lambda: self.now)
self.ids = {'session_id':'s', 'turn_id':'t', 'task_id':'task'}
self.b.begin(user_message='innolive AI 모델의 변천사를 알려줘',platform='discord',**self.ids)

def test_intent_preserves_actions_and_deep_research(self):
for q in ['innolive AI 모델의 변천사를 알려줘','Framework 노션 보고서 목록 찾아줘','위키 서버 구조 설명해줘']:
self.assertTrue(m.ordinary_question(q))
for q in ['innolive 코드를 수정해줘','Framework 모든 문서를 조사하고 정리해줘','위키에 이 결과 보내줘','innolive 코드 분석해줘','innolive 코드 동작 알려줘','innolive GPU 서버 상태 알려줘','Notion 상세히 알려줘','날씨 알려줘']:
self.assertFalse(m.ordinary_question(q),q)
history=[{'role':'user','content':'innolive AI 모델 알려줘'}]
self.assertTrue(m.ordinary_question('그 모델의 실험 보고서를 찾아줘',history))
history += [{'role':'tool','content':'evidence'} for _ in range(12)]
self.assertTrue(m.ordinary_question('그 모델의 실험 보고서를 찾아줘',history))

def test_current_tool_pairs_and_saved_history_preserved(self):
history=[{'role':'system','content':'keep'}, {'role':'user','content':'old'}, {'role':'assistant','tool_calls':[{'id':'oldcall'}]}, {'role':'tool','tool_call_id':'oldcall','content':'x'*200000}, {'role':'assistant','content':'old answer'}, {'role':'user','content':'current'}, {'type':'function_call','call_id':'new','name':'read'}, {'type':'function_call_output','call_id':'new','output':'new evidence'}]
before=repr(history)
selected=m.select_messages(history)
self.assertEqual(repr(history),before)
self.assertEqual(selected[-3:],history[-3:])
self.assertEqual(selected[0],history[0])
self.assertNotIn('x'*100,repr(selected))
self.assertNotIn('oldcall',repr(selected))

def test_recent_plain_history_budget_and_codex_input(self):
old=[{'role':'user','content':'u'*5000},{'role':'assistant','content':'a'*20000},{'role':'user','content':'current'}]
out=self.b.request({'input':old,'instructions':'unchanged','tools':[{'name':'tool_call'}]},api_call_count=1,**self.ids)['request']
self.assertLess(len(str(out['input'])),8200)
self.assertEqual(out['instructions'],'unchanged')
self.assertEqual(len(old[1]['content']),20000)
linked=[{'role':'user','content':'Framework AI 문서 알려줘'},{'role':'assistant','content':'a'*20000+'https://app.notion.com/p/123'},{'role':'user','content':'이 문서를 요약해줘'}]
chosen=m.select_messages(linked)
self.assertIn('https://app.notion.com/p/123',repr(chosen))
self.assertIn('Framework AI 문서 알려줘',repr(chosen))

def test_parallel_bridge_budget_and_duplicate_hooks(self):
for i in range(3):
name=m.SOURCE+'get_sources_context'
self.assertIsNone(self.b.tool('tool_call',{'calls':[{'name':name,'arguments':{}}]},tool_call_id=str(i),**self.ids))
self.assertIsNone(self.b.tool(name,{},tool_call_id=str(i),**self.ids))
self.assertEqual(self.b.tool(name,{},tool_call_id='4',**self.ids)['action'],'block')
out=self.b.request({'messages':[],'tools':[1],'tool_choice':'auto','parallel_tool_calls':True},**self.ids)['request']
self.assertEqual(out['tools'],[]);self.assertNotIn('tool_choice',out)

def test_deadline_forces_answer_and_blocks_more_tools(self):
self.now=91
self.assertEqual(self.b.request({'input':[],'tools':[1]},**self.ids)['request']['tools'],[])
self.assertEqual(self.b.tool(m.SOURCE+'search_notion',{},**self.ids)['action'],'block')

def test_three_tool_rounds_then_final_and_nonmatching_turn_unchanged(self):
self.assertEqual(self.b.request({'input':[],'tools':[1]},api_call_count=4,**self.ids)['request']['tools'],[])
self.assertIsNone(self.b.request({'input':[]},session_id='other',turn_id='t'))
self.assertIsNone(self.b.begin(user_message='Framework 위키 업데이트해',platform='discord',session_id='other',turn_id='t'))
self.assertIsNone(self.b.begin(user_message='innolive 알려줘',platform='cli',session_id='other',turn_id='t'))

def test_mutation_blocked_and_new_turn_resets_budget(self):
self.assertEqual(self.b.tool('terminal',{},**self.ids)['action'],'block')
self.b.tool(m.SOURCE+'search_notion',{},**self.ids)
self.b.begin(user_message='위키 구조 알려줘',platform='discord',**self.ids)
self.assertEqual(self.b.turns[self.b.key(**self.ids)].reads,0)


class InstallTests(unittest.TestCase):
def test_preserves_config_and_backups_and_repeat_is_idempotent(self):
from tempfile import TemporaryDirectory
import yaml
from configure_wiki_budget import configure
with TemporaryDirectory() as d:
home=Path(d);path=home/'config.yaml'
config={'model':{'default':'gpt-6-luna'},'agent':{'reasoning_effort':'max','max_turns':150},'plugins':{'enabled':['existing'],'entries':{'existing':{'settings':{'keep':True}}}},'mcp_servers':{'other':{'headers':{'Authorization':'private'}}}}
path.write_text(yaml.safe_dump(config));before=path.read_text()
backup=configure(home);configure(home)
result=yaml.safe_load(path.read_text())
self.assertEqual(result['agent'],config['agent']);self.assertEqual(result['mcp_servers'],config['mcp_servers'])
self.assertEqual(result['plugins']['enabled'],['existing','framework-wiki-budget'])
self.assertEqual(result['plugins']['entries'],config['plugins']['entries'])
self.assertEqual((backup/'config.yaml').read_text(),before)
self.assertEqual((backup/'config.yaml').stat().st_mode & 0o777,0o600)
self.assertTrue((home/'plugins/framework-wiki-budget/plugin.yaml').exists())

if __name__=='__main__':unittest.main()
42 changes: 42 additions & 0 deletions deploy/hermes/verify_wiki_budget.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Exercise the real Hermes plugin loader and middleware across isolated homes."""
import argparse
import os
from pathlib import Path
import shutil
import sys
from tempfile import TemporaryDirectory


def verify(runtime):
sys.path.insert(0,str(runtime))
import hermes_bootstrap # noqa: F401
from hermes_cli.plugins import get_plugin_manager, invoke_hook
from hermes_cli.middleware import apply_llm_request_middleware
original=os.environ.get('HERMES_HOME')
try:
with TemporaryDirectory(prefix='wiki-budget-contract-') as directory:
root=Path(directory);a=root/'a';b=root/'b';a.mkdir();b.mkdir()
shutil.copytree(Path(__file__).with_name('framework-wiki-budget'),a/'plugins/framework-wiki-budget')
(a/'config.yaml').write_text('plugins:\n enabled: [framework-wiki-budget]\n')
(b/'config.yaml').write_text('plugins:\n enabled: []\n')
ids={'session_id':'contract','turn_id':'turn','task_id':'task'}
payload={'input':[{'role':'user','content':'old'},{'role':'assistant','content':'x'*20000},{'role':'user','content':'innolive AI 모델의 변천사를 알려줘'}],'tools':[{'name':'tool_call'}]}
for home,enabled in [(a,True),(b,False),(a,True)]:
os.environ['HERMES_HOME']=str(home)
manager=get_plugin_manager();manager.discover_and_load()
invoke_hook('pre_llm_call',user_message='innolive AI 모델의 변천사를 알려줘',platform='discord',conversation_history=[],**ids)
actual=apply_llm_request_middleware(payload,api_call_count=4,**ids).payload
if enabled:
assert len(str(actual['input']))<5000 and actual['tools']==[]
blocked=invoke_hook('pre_tool_call',tool_name='terminal',args={},tool_call_id='blocked',**ids)
assert any(row and row.get('action')=='block' for row in blocked)
else: assert actual==payload
assert len(payload['input'][1]['content'])==20000
print('Hermes plugin discovery, middleware, tool policy and A -> B -> A home isolation passed')
finally:
if original is None:os.environ.pop('HERMES_HOME',None)
else:os.environ['HERMES_HOME']=original

if __name__=='__main__':
parser=argparse.ArgumentParser();parser.add_argument('--runtime',type=Path,default=Path('/home/chaeyn/.hermes/hermes-agent'))
verify(parser.parse_args().runtime)
Loading