ollaagent 0.1.0__py3-none-any.whl
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.
- ollaAgent/__init__.py +0 -0
- ollaAgent/agent.py +695 -0
- ollaAgent/config_loader.py +128 -0
- ollaAgent/memory.py +130 -0
- ollaAgent/ollama_client.py +26 -0
- ollaAgent/permissions.py +49 -0
- ollaAgent/plan_mode.py +73 -0
- ollaAgent/subagent.py +108 -0
- ollaAgent/tool_bash.py +38 -0
- ollaagent-0.1.0.dist-info/METADATA +117 -0
- ollaagent-0.1.0.dist-info/RECORD +14 -0
- ollaagent-0.1.0.dist-info/WHEEL +4 -0
- ollaagent-0.1.0.dist-info/entry_points.txt +2 -0
- ollaagent-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import yaml
|
|
5
|
+
from ollaAgent.permissions import DEFAULT_DENY_PATTERNS, PermissionMode
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
# ──────────────────────────────────────────
|
|
9
|
+
# Config 파일 경로 정의
|
|
10
|
+
# ──────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
GLOBAL_CONFIG_PATH = Path.home() / ".agents" / "config.yaml"
|
|
13
|
+
PROJECT_CONFIG_PATH = Path.cwd() / "config.yaml"
|
|
14
|
+
LOCAL_CONFIG_PATH = Path.cwd() / ".agents" / "config.yaml"
|
|
15
|
+
|
|
16
|
+
DEFAULT_AGENTS_MD = "AGENT.md"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ──────────────────────────────────────────
|
|
20
|
+
# AgentConfig Model
|
|
21
|
+
# ──────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AgentConfig(BaseModel):
|
|
25
|
+
"""전체 agent 동작을 제어하는 설정 모델.
|
|
26
|
+
|
|
27
|
+
계층 우선순위: global < project < local
|
|
28
|
+
각 레벨에서 기재된 키만 상위 값을 오버라이드한다.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
model: str = "qwen3-coder-next:latest"
|
|
32
|
+
permission_mode: PermissionMode = PermissionMode.PROMPT
|
|
33
|
+
token_threshold: int = 80_000
|
|
34
|
+
max_iterations: int = 10
|
|
35
|
+
deny_patterns: list[str] = DEFAULT_DENY_PATTERNS
|
|
36
|
+
agents_md_path: str = DEFAULT_AGENTS_MD
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ──────────────────────────────────────────
|
|
40
|
+
# YAML I/O
|
|
41
|
+
# ──────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _load_yaml(path: Path) -> dict[str, Any]:
|
|
45
|
+
"""YAML 파일을 읽어 dict로 반환한다. 파일 없거나 파싱 오류 시 빈 dict 반환."""
|
|
46
|
+
if not path.exists():
|
|
47
|
+
return {}
|
|
48
|
+
try:
|
|
49
|
+
with path.open(encoding="utf-8") as fh:
|
|
50
|
+
data = yaml.safe_load(fh)
|
|
51
|
+
return data if isinstance(data, dict) else {}
|
|
52
|
+
except yaml.YAMLError as exc:
|
|
53
|
+
print(f"[Config] YAML 파싱 오류 ({path}): {exc} — 해당 레벨 skip")
|
|
54
|
+
return {}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _ensure_global_config(path: Path) -> None:
|
|
58
|
+
"""global config 파일이 없으면 기본값으로 자동 생성한다."""
|
|
59
|
+
if path.exists():
|
|
60
|
+
return
|
|
61
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
default = AgentConfig()
|
|
63
|
+
data = {
|
|
64
|
+
"model": default.model,
|
|
65
|
+
"permission_mode": default.permission_mode.value,
|
|
66
|
+
"token_threshold": default.token_threshold,
|
|
67
|
+
"max_iterations": default.max_iterations,
|
|
68
|
+
"deny_patterns": default.deny_patterns,
|
|
69
|
+
"agents_md_path": default.agents_md_path,
|
|
70
|
+
}
|
|
71
|
+
with path.open("w", encoding="utf-8") as fh:
|
|
72
|
+
yaml.dump(data, fh, allow_unicode=True, default_flow_style=False)
|
|
73
|
+
print(f"[Config] global config 생성: {path}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ──────────────────────────────────────────
|
|
77
|
+
# 계층 머지
|
|
78
|
+
# ──────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
|
82
|
+
"""override에 기재된 키만 base를 교체한다."""
|
|
83
|
+
result = dict(base)
|
|
84
|
+
for key, value in override.items():
|
|
85
|
+
if value is not None:
|
|
86
|
+
result[key] = value
|
|
87
|
+
return result
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def load_config(
|
|
91
|
+
global_path: Path = GLOBAL_CONFIG_PATH,
|
|
92
|
+
project_path: Path = PROJECT_CONFIG_PATH,
|
|
93
|
+
local_path: Path = LOCAL_CONFIG_PATH,
|
|
94
|
+
) -> AgentConfig:
|
|
95
|
+
"""global → project → local 순서로 config를 머지하여 AgentConfig를 반환한다."""
|
|
96
|
+
_ensure_global_config(global_path)
|
|
97
|
+
|
|
98
|
+
raw = {}
|
|
99
|
+
for path in (global_path, project_path, local_path):
|
|
100
|
+
raw = _merge(raw, _load_yaml(path))
|
|
101
|
+
|
|
102
|
+
return AgentConfig(
|
|
103
|
+
**{k: v for k, v in raw.items() if k in AgentConfig.model_fields}
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ──────────────────────────────────────────
|
|
108
|
+
# System Prompt Builder
|
|
109
|
+
# ──────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def build_system_prompt(agents_md_path: str) -> str:
|
|
113
|
+
"""AGENTS.md 내용을 읽어 system prompt 앞에 prepend한다.
|
|
114
|
+
|
|
115
|
+
파일이 없으면 기본 system prompt만 반환한다.
|
|
116
|
+
"""
|
|
117
|
+
base_prompt = "You are an expert coder. Use tools when needed."
|
|
118
|
+
path = Path(agents_md_path)
|
|
119
|
+
if not path.exists():
|
|
120
|
+
return base_prompt
|
|
121
|
+
content = path.read_text(encoding="utf-8").strip()
|
|
122
|
+
token_estimate = len(content) // 4
|
|
123
|
+
if token_estimate > 5_000:
|
|
124
|
+
print(
|
|
125
|
+
f"[Config] 경고: {agents_md_path} 크기 ~{token_estimate:,} tokens "
|
|
126
|
+
f"— 매 호출마다 context에 포함됨"
|
|
127
|
+
)
|
|
128
|
+
return f"{content}\n\n---\n\n{base_prompt}"
|
ollaAgent/memory.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import uuid
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
MEMORY_PATH = Path.cwd() / ".agents" / "memory.json"
|
|
11
|
+
SESSION_DIR = Path.cwd() / ".agents" / "sessions"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ──────────────────────────────────────────
|
|
15
|
+
# Data Model
|
|
16
|
+
# ──────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MemoryEntry(BaseModel):
|
|
20
|
+
"""단일 영구 메모리 항목."""
|
|
21
|
+
|
|
22
|
+
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
23
|
+
content: str
|
|
24
|
+
tags: list[str] = Field(default_factory=list)
|
|
25
|
+
created_at: str = Field(
|
|
26
|
+
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ──────────────────────────────────────────
|
|
31
|
+
# Session Memory (JSON 기반)
|
|
32
|
+
# ──────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SessionMemory:
|
|
36
|
+
"""JSON 파일 기반 영구 메모리. /memory 커맨드로 관리한다."""
|
|
37
|
+
|
|
38
|
+
VERSION = "1"
|
|
39
|
+
|
|
40
|
+
def __init__(self, path: Path = MEMORY_PATH) -> None:
|
|
41
|
+
self._path = path
|
|
42
|
+
self._entries: list[MemoryEntry] = []
|
|
43
|
+
self._load()
|
|
44
|
+
|
|
45
|
+
def _load(self) -> None:
|
|
46
|
+
"""JSON 파일에서 메모리를 로드한다. 파일 없거나 파싱 오류 시 빈 상태로 시작."""
|
|
47
|
+
if not self._path.exists():
|
|
48
|
+
return
|
|
49
|
+
try:
|
|
50
|
+
with self._path.open(encoding="utf-8") as fh:
|
|
51
|
+
data = json.load(fh)
|
|
52
|
+
self._entries = [MemoryEntry(**e) for e in data.get("entries", [])]
|
|
53
|
+
except (json.JSONDecodeError, KeyError, ValueError):
|
|
54
|
+
self._entries = []
|
|
55
|
+
|
|
56
|
+
def save(self) -> None:
|
|
57
|
+
"""현재 메모리를 JSON 파일에 저장한다."""
|
|
58
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
data = {
|
|
60
|
+
"version": self.VERSION,
|
|
61
|
+
"entries": [e.model_dump() for e in self._entries],
|
|
62
|
+
}
|
|
63
|
+
with self._path.open("w", encoding="utf-8") as fh:
|
|
64
|
+
json.dump(data, fh, ensure_ascii=False, indent=2)
|
|
65
|
+
|
|
66
|
+
def add(self, content: str, tags: list[str] | None = None) -> MemoryEntry:
|
|
67
|
+
"""새 메모리 항목을 추가하고 즉시 저장한다."""
|
|
68
|
+
entry = MemoryEntry(content=content, tags=tags or [])
|
|
69
|
+
self._entries.append(entry)
|
|
70
|
+
self.save()
|
|
71
|
+
return entry
|
|
72
|
+
|
|
73
|
+
def search(self, query: str) -> list[MemoryEntry]:
|
|
74
|
+
"""content 또는 tags에서 query를 포함하는 항목을 반환한다."""
|
|
75
|
+
q = query.lower()
|
|
76
|
+
return [
|
|
77
|
+
e
|
|
78
|
+
for e in self._entries
|
|
79
|
+
if q in e.content.lower() or any(q in t.lower() for t in e.tags)
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
def all(self) -> list[MemoryEntry]:
|
|
83
|
+
"""전체 메모리 항목을 반환한다."""
|
|
84
|
+
return list(self._entries)
|
|
85
|
+
|
|
86
|
+
def clear(self) -> int:
|
|
87
|
+
"""전체 메모리를 초기화하고 삭제된 개수를 반환한다."""
|
|
88
|
+
count = len(self._entries)
|
|
89
|
+
self._entries = []
|
|
90
|
+
self.save()
|
|
91
|
+
return count
|
|
92
|
+
|
|
93
|
+
def to_context_string(self) -> str:
|
|
94
|
+
"""메모리 항목을 system prompt에 주입할 텍스트로 변환한다."""
|
|
95
|
+
if not self._entries:
|
|
96
|
+
return ""
|
|
97
|
+
lines = ["## Persistent Memory"]
|
|
98
|
+
for i, e in enumerate(self._entries, 1):
|
|
99
|
+
tag_str = f" [{', '.join(e.tags)}]" if e.tags else ""
|
|
100
|
+
lines.append(f"{i}. {e.content}{tag_str}")
|
|
101
|
+
return "\n".join(lines)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ──────────────────────────────────────────
|
|
105
|
+
# Session Save / Load
|
|
106
|
+
# ──────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def save_session(messages: list[dict], path: Path) -> None:
|
|
110
|
+
"""대화 히스토리를 JSON 파일로 저장한다."""
|
|
111
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
data = {
|
|
113
|
+
"version": "1",
|
|
114
|
+
"saved_at": datetime.now(timezone.utc).isoformat(),
|
|
115
|
+
"messages": messages,
|
|
116
|
+
}
|
|
117
|
+
with path.open("w", encoding="utf-8") as fh:
|
|
118
|
+
json.dump(data, fh, ensure_ascii=False, indent=2)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def load_session(path: Path) -> list[dict]:
|
|
122
|
+
"""저장된 세션 JSON에서 messages를 로드한다. 실패 시 빈 리스트 반환."""
|
|
123
|
+
if not path.exists():
|
|
124
|
+
return []
|
|
125
|
+
try:
|
|
126
|
+
with path.open(encoding="utf-8") as fh:
|
|
127
|
+
data = json.load(fh)
|
|
128
|
+
return data.get("messages", [])
|
|
129
|
+
except (json.JSONDecodeError, KeyError):
|
|
130
|
+
return []
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from dotenv import load_dotenv
|
|
3
|
+
from ollama import Client
|
|
4
|
+
|
|
5
|
+
load_dotenv()
|
|
6
|
+
|
|
7
|
+
client = Client(
|
|
8
|
+
host='https://ollama.nabee.ai.kr',
|
|
9
|
+
headers={
|
|
10
|
+
'CF-Access-Client-Id': os.getenv('CF_ACCESS_CLIENT_ID'),
|
|
11
|
+
'CF-Access-Client-Secret': os.getenv('CF_ACCESS_CLIENT_SECRET'),
|
|
12
|
+
}
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
response = client.chat(
|
|
16
|
+
model='qwen3-coder-next:latest',
|
|
17
|
+
messages=[{'role': 'user', 'content': 'Hello'}]
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
thinking = response['message'].get('thinking')
|
|
21
|
+
if thinking:
|
|
22
|
+
print("=== Thinking ===")
|
|
23
|
+
print(thinking)
|
|
24
|
+
print("=== Response ===")
|
|
25
|
+
|
|
26
|
+
print(response['message']['content'])
|
ollaAgent/permissions.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from enum import Enum
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
from rich.prompt import Confirm
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PermissionMode(str, Enum):
|
|
9
|
+
"""bash tool 실행 허가 모드."""
|
|
10
|
+
|
|
11
|
+
AUTO = "auto" # deny list 외 모두 자동 허가
|
|
12
|
+
PROMPT = "prompt" # deny list 외 사용자에게 확인
|
|
13
|
+
DENY = "deny" # 모든 명령 거부
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
DEFAULT_DENY_PATTERNS: list[str] = [
|
|
17
|
+
r"rm\s+.*-rf|rm\s+-rf", # 재귀 삭제
|
|
18
|
+
r"sudo\s+", # 권한 상승
|
|
19
|
+
r"dd\s+if=", # 디스크 덮어쓰기
|
|
20
|
+
r"mkfs", # 파일시스템 포맷
|
|
21
|
+
r":\(\)\{.*\}", # fork bomb
|
|
22
|
+
r">\s*/dev/", # 디바이스 직접 쓰기
|
|
23
|
+
r"curl.+\|.+sh|wget.+\|.+sh", # 원격 스크립트 실행
|
|
24
|
+
r"chmod\s+777", # 위험한 권한 변경
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PermissionConfig(BaseModel):
|
|
29
|
+
"""bash tool 실행 허가 설정."""
|
|
30
|
+
|
|
31
|
+
mode: PermissionMode = PermissionMode.PROMPT
|
|
32
|
+
deny_patterns: list[str] = DEFAULT_DENY_PATTERNS
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def is_denied(command: str, config: PermissionConfig) -> tuple[bool, str]:
|
|
36
|
+
"""deny_patterns 중 하나라도 매칭되면 (True, 매칭된 패턴)을 반환한다."""
|
|
37
|
+
for pattern in config.deny_patterns:
|
|
38
|
+
if re.search(pattern, command, re.IGNORECASE):
|
|
39
|
+
return True, pattern
|
|
40
|
+
return False, ""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def request_permission(command: str, config: PermissionConfig) -> bool:
|
|
44
|
+
"""mode에 따라 실행 허가 여부를 반환한다. deny 체크는 포함하지 않는다."""
|
|
45
|
+
if config.mode == PermissionMode.DENY:
|
|
46
|
+
return False
|
|
47
|
+
if config.mode == PermissionMode.AUTO:
|
|
48
|
+
return True
|
|
49
|
+
return Confirm.ask(f"[yellow]Allow command?[/yellow]\n {command}")
|
ollaAgent/plan_mode.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ollama import Client
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.live import Live
|
|
8
|
+
from rich.markdown import Markdown
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
PLAN_SYSTEM_PREFIX = (
|
|
14
|
+
"You are in PLAN MODE. Do NOT call any tools. "
|
|
15
|
+
"Only plan, no execution. "
|
|
16
|
+
"Return a structured step-by-step plan."
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ──────────────────────────────────────────
|
|
21
|
+
# Public API
|
|
22
|
+
# ──────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def run_plan(
|
|
26
|
+
task: str,
|
|
27
|
+
client: Client,
|
|
28
|
+
model: str,
|
|
29
|
+
base_prompt: str = "",
|
|
30
|
+
) -> str:
|
|
31
|
+
"""plan 모드: tools=[] 로 모델을 호출해 실행 없이 계획만 반환한다.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
task: 계획할 작업 설명.
|
|
35
|
+
client: Ollama 클라이언트.
|
|
36
|
+
model: 사용할 모델 이름.
|
|
37
|
+
base_prompt: 추가 컨텍스트 (optional).
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
모델이 생성한 계획 텍스트.
|
|
41
|
+
"""
|
|
42
|
+
sys_content = (
|
|
43
|
+
f"{PLAN_SYSTEM_PREFIX}\n\n{base_prompt}" if base_prompt else PLAN_SYSTEM_PREFIX
|
|
44
|
+
)
|
|
45
|
+
messages: list[dict[str, str]] = [
|
|
46
|
+
{"role": "system", "content": sys_content},
|
|
47
|
+
{"role": "user", "content": task},
|
|
48
|
+
]
|
|
49
|
+
console.print(Panel("[bold blue]PLAN MODE[/] — tools disabled", style="blue"))
|
|
50
|
+
stream = client.chat(
|
|
51
|
+
model=model,
|
|
52
|
+
messages=messages,
|
|
53
|
+
tools=[],
|
|
54
|
+
stream=True,
|
|
55
|
+
)
|
|
56
|
+
return _stream_plan(stream)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ──────────────────────────────────────────
|
|
60
|
+
# Private Helpers
|
|
61
|
+
# ──────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _stream_plan(stream: Any) -> str:
|
|
65
|
+
"""스트림을 소비하며 content를 누적하고 Live 렌더링 후 반환한다."""
|
|
66
|
+
content = ""
|
|
67
|
+
with Live(console=console, refresh_per_second=10) as live:
|
|
68
|
+
for chunk in stream:
|
|
69
|
+
piece = chunk.get("message", {}).get("content", "")
|
|
70
|
+
if piece:
|
|
71
|
+
content += piece
|
|
72
|
+
live.update(Markdown(content))
|
|
73
|
+
return content
|
ollaAgent/subagent.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from multiprocessing import Pool
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ──────────────────────────────────────────
|
|
13
|
+
# Data Model
|
|
14
|
+
# ──────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class SubagentTask:
|
|
19
|
+
"""단일 서브에이전트 실행 단위. multiprocessing pickle을 위해 기본 타입만 사용한다."""
|
|
20
|
+
|
|
21
|
+
name: str
|
|
22
|
+
task: str
|
|
23
|
+
model: str
|
|
24
|
+
host: str
|
|
25
|
+
cf_client_id: str
|
|
26
|
+
cf_client_secret: str
|
|
27
|
+
system_prompt: str = "You are an expert coder. Answer concisely."
|
|
28
|
+
max_iterations: int = 5
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ──────────────────────────────────────────
|
|
32
|
+
# Worker (모듈 레벨 — spawn 방식 pickle 조건)
|
|
33
|
+
# ──────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _simple_loop(
|
|
37
|
+
messages: list[dict],
|
|
38
|
+
client: Any,
|
|
39
|
+
model: str,
|
|
40
|
+
max_iterations: int,
|
|
41
|
+
) -> str:
|
|
42
|
+
"""Rich 없이 스트리밍으로 단일 응답을 수집해 반환한다.
|
|
43
|
+
|
|
44
|
+
tools=[] 이므로 tool_call 없이 한 번에 완료된다.
|
|
45
|
+
"""
|
|
46
|
+
final = ""
|
|
47
|
+
for _ in range(max_iterations):
|
|
48
|
+
stream = client.chat(
|
|
49
|
+
model=model,
|
|
50
|
+
messages=messages,
|
|
51
|
+
tools=[],
|
|
52
|
+
stream=True,
|
|
53
|
+
)
|
|
54
|
+
content = ""
|
|
55
|
+
for chunk in stream:
|
|
56
|
+
content += chunk.get("message", {}).get("content", "")
|
|
57
|
+
messages.append({"role": "assistant", "content": content})
|
|
58
|
+
final = content
|
|
59
|
+
break # tools=[] 이므로 첫 응답에서 완료
|
|
60
|
+
return final
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _worker(task: SubagentTask) -> tuple[str, str]:
|
|
64
|
+
"""워커 프로세스: 독립 Client로 태스크를 실행하고 (name, result) 튜플을 반환한다.
|
|
65
|
+
|
|
66
|
+
Rich Live는 사용하지 않는다 — 멀티프로세싱 환경에서 터미널 출력이 충돌한다.
|
|
67
|
+
Client import를 함수 내부에 위치시켜 worker 프로세스에서 fresh import가 이루어지도록 한다.
|
|
68
|
+
"""
|
|
69
|
+
from ollama import Client # noqa: PLC0415 — subprocess fresh import 의도적
|
|
70
|
+
|
|
71
|
+
client = Client(
|
|
72
|
+
host=task.host,
|
|
73
|
+
headers={
|
|
74
|
+
"CF-Access-Client-Id": task.cf_client_id,
|
|
75
|
+
"CF-Access-Client-Secret": task.cf_client_secret,
|
|
76
|
+
},
|
|
77
|
+
)
|
|
78
|
+
messages: list[dict[str, str]] = [
|
|
79
|
+
{"role": "system", "content": task.system_prompt},
|
|
80
|
+
{"role": "user", "content": task.task},
|
|
81
|
+
]
|
|
82
|
+
result = _simple_loop(messages, client, task.model, task.max_iterations)
|
|
83
|
+
return (task.name, result)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ──────────────────────────────────────────
|
|
87
|
+
# Public API
|
|
88
|
+
# ──────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def run_subagents(
|
|
92
|
+
tasks: list[SubagentTask],
|
|
93
|
+
workers: int = 4,
|
|
94
|
+
) -> list[tuple[str, str]]:
|
|
95
|
+
"""멀티프로세싱으로 SubagentTask 목록을 병렬 실행하고 (name, result) 목록을 반환한다.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
tasks: 실행할 SubagentTask 목록.
|
|
99
|
+
workers: 최대 워커 프로세스 수 (기본 4, tasks 수가 더 적으면 tasks 수 사용).
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
[(name, result), ...] 형태의 결과 목록.
|
|
103
|
+
"""
|
|
104
|
+
if not tasks:
|
|
105
|
+
return []
|
|
106
|
+
num_workers = min(workers, len(tasks))
|
|
107
|
+
with Pool(processes=num_workers) as pool:
|
|
108
|
+
return pool.map(_worker, tasks)
|
ollaAgent/tool_bash.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from ollaAgent.permissions import PermissionConfig, is_denied, request_permission
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def tool_bash(args: dict[str, Any], config: PermissionConfig) -> str:
|
|
8
|
+
"""bash 명령을 실행하고 stdout/stderr를 반환한다.
|
|
9
|
+
|
|
10
|
+
실행 순서:
|
|
11
|
+
1. deny_patterns 매칭 여부 확인 → 매칭 시 즉시 차단
|
|
12
|
+
2. mode에 따라 사용자 허가 요청 (prompt) 또는 자동 허가 (auto)
|
|
13
|
+
3. subprocess로 실행 (shell=True 금지, timeout=30)
|
|
14
|
+
"""
|
|
15
|
+
command = args.get("command", "")
|
|
16
|
+
if not command:
|
|
17
|
+
return "ERROR: No command provided"
|
|
18
|
+
|
|
19
|
+
denied, pattern = is_denied(command, config)
|
|
20
|
+
if denied:
|
|
21
|
+
return f"ERROR: Blocked - command matches deny pattern: '{pattern}'"
|
|
22
|
+
|
|
23
|
+
if not request_permission(command, config):
|
|
24
|
+
return "ERROR: Permission denied by user"
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
result = subprocess.run(
|
|
28
|
+
["bash", "-c", command],
|
|
29
|
+
capture_output=True,
|
|
30
|
+
text=True,
|
|
31
|
+
timeout=30,
|
|
32
|
+
)
|
|
33
|
+
output = result.stdout or result.stderr
|
|
34
|
+
return output.strip() if output.strip() else "(no output)"
|
|
35
|
+
except subprocess.TimeoutExpired:
|
|
36
|
+
return "ERROR: Timeout (30s)"
|
|
37
|
+
except Exception as exc:
|
|
38
|
+
return f"ERROR: {exc}"
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ollaagent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local LLM agent powered by ollama — memory, plan mode, subagents
|
|
5
|
+
License-File: LICENSE
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: ollama==0.6.1
|
|
8
|
+
Requires-Dist: pydantic==2.12.5
|
|
9
|
+
Requires-Dist: python-dotenv==1.2.2
|
|
10
|
+
Requires-Dist: pyyaml>=6.0
|
|
11
|
+
Requires-Dist: rich==14.3.3
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# ollaAgent
|
|
15
|
+
|
|
16
|
+
A local LLM agent powered by [ollama](https://ollama.com) — with persistent memory, plan mode, and parallel subagents.
|
|
17
|
+
|
|
18
|
+
[](https://www.python.org)
|
|
19
|
+
[](LICENSE)
|
|
20
|
+
|
|
21
|
+
## Features
|
|
22
|
+
|
|
23
|
+
| Feature | Description |
|
|
24
|
+
|---------|-------------|
|
|
25
|
+
| **Agent Loop** | Iterative tool-calling loop with `run_python`, `run_bash`, `read_file`, `write_file`, `list_files` |
|
|
26
|
+
| **Persistent Memory** | JSON-backed memory with `/memory add/list/search/clear` commands |
|
|
27
|
+
| **Session Saving** | Conversation history auto-saved to `.agents/sessions/` on exit |
|
|
28
|
+
| **Plan Mode** | `/plan <task>` — generates a structured step-by-step plan without executing tools |
|
|
29
|
+
| **Subagents** | `/subagent` — runs multiple ollama instances in parallel via `multiprocessing.Pool` |
|
|
30
|
+
| **Permission Control** | Configurable allow/deny patterns for bash commands |
|
|
31
|
+
| **Cloudflare Access** | Supports CF-Access headers for tunneled ollama endpoints |
|
|
32
|
+
|
|
33
|
+
## Requirements
|
|
34
|
+
|
|
35
|
+
- Python 3.11+
|
|
36
|
+
- [ollama](https://ollama.com) running locally (or via Cloudflare Access)
|
|
37
|
+
- [uv](https://docs.astral.sh/uv/)
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
git clone https://github.com/github010000/ollaAgent
|
|
43
|
+
cd ollaAgent
|
|
44
|
+
uv sync
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# Start the agent
|
|
51
|
+
uv run ollaagent
|
|
52
|
+
|
|
53
|
+
# With a specific model
|
|
54
|
+
uv run ollaagent --model qwen2.5-coder:7b
|
|
55
|
+
|
|
56
|
+
# With a remote ollama host
|
|
57
|
+
uv run ollaagent --host https://your-ollama.example.com
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Built-in Commands
|
|
61
|
+
|
|
62
|
+
| Command | Description |
|
|
63
|
+
|---------|-------------|
|
|
64
|
+
| `/plan <task>` | Generate a step-by-step plan (no execution) |
|
|
65
|
+
| `/subagent` | Run tasks in parallel across multiple ollama instances |
|
|
66
|
+
| `/memory add <text>` | Add an entry to persistent memory |
|
|
67
|
+
| `/memory list` | List all memory entries |
|
|
68
|
+
| `/memory search <query>` | Search memory by keyword |
|
|
69
|
+
| `/memory clear` | Clear all memory entries |
|
|
70
|
+
| `/exit` | Exit the agent |
|
|
71
|
+
|
|
72
|
+
## Subagent Usage
|
|
73
|
+
|
|
74
|
+
Single model across all tasks:
|
|
75
|
+
```
|
|
76
|
+
/subagent
|
|
77
|
+
> --model llama3:8b task one | task two | task three
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Per-task model assignment:
|
|
81
|
+
```
|
|
82
|
+
> @qwen2.5-coder:7b write a sorting algorithm | @llama3:8b explain it
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Configuration
|
|
86
|
+
|
|
87
|
+
Create a `.env` file in the project root:
|
|
88
|
+
|
|
89
|
+
```env
|
|
90
|
+
OLLAMA_HOST=http://localhost:11434
|
|
91
|
+
CF_CLIENT_ID=
|
|
92
|
+
CF_CLIENT_SECRET=
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Project Structure
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
ollaAgent/
|
|
99
|
+
├── agent.py # Main agent loop & CLI entry point
|
|
100
|
+
├── memory.py # Persistent memory (JSON)
|
|
101
|
+
├── plan_mode.py # Plan-only mode (tools=[])
|
|
102
|
+
├── subagent.py # Parallel subagents via multiprocessing
|
|
103
|
+
├── tool_bash.py # Bash tool with permission control
|
|
104
|
+
├── permissions.py # Allow/deny pattern matching
|
|
105
|
+
├── config_loader.py # YAML config & system prompt builder
|
|
106
|
+
└── ollama_client.py # Ollama client factory
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Running Tests
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
uv run pytest
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
MIT — see [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
ollaAgent/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
ollaAgent/agent.py,sha256=JXVFdOJcR19CHLM0dh2zKLUWYOXFdxWWIytoFUh_SkA,24643
|
|
3
|
+
ollaAgent/config_loader.py,sha256=v-Q712wqaLsZ3ssYhgt3T7WgFNgy8eh__H9-G0qu0tI,4970
|
|
4
|
+
ollaAgent/memory.py,sha256=qzRxl0YaKHUBIA3WRZCpJU0_GWr1tw4SO97SHUhcoMA,4762
|
|
5
|
+
ollaAgent/ollama_client.py,sha256=J8wRHKKs0FpEyAvJeOJe0XGadRihLU1xbC0dHKGRWyE,595
|
|
6
|
+
ollaAgent/permissions.py,sha256=_D4H2Xn1xdhIF1NDvMvWxn6dnx7bHFPDDoMlpXm7_iA,1607
|
|
7
|
+
ollaAgent/plan_mode.py,sha256=9sGGe_fzueJ_tgLLdYttqQFGivyfIlgG8fPJWgCxdo0,2300
|
|
8
|
+
ollaAgent/subagent.py,sha256=TDRfa42NCzYFRCNetOvmI7m1PS0l0keTUG8A1Xz8xXw,3713
|
|
9
|
+
ollaAgent/tool_bash.py,sha256=5cqcB1U8YBfsoIOyAdgw2H9HEajB1rxDETYkNr3_AOY,1268
|
|
10
|
+
ollaagent-0.1.0.dist-info/METADATA,sha256=qcBHudiAwmnR1NnfcCyZwXRXaBx6cwLpSaLJ9g6BeV8,3315
|
|
11
|
+
ollaagent-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
12
|
+
ollaagent-0.1.0.dist-info/entry_points.txt,sha256=c7ENjzdJDXPpSvVHZTdM2M_cNZEPLSX3cm5z9AUmUMA,51
|
|
13
|
+
ollaagent-0.1.0.dist-info/licenses/LICENSE,sha256=ePCDpHnSmBcdbm9YJSHND3Gir6wnkyOIcpo6tsg1m9A,1069
|
|
14
|
+
ollaagent-0.1.0.dist-info/RECORD,,
|