ollaagent 0.1.2__tar.gz → 0.1.4__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.
- ollaagent-0.1.4/CHANGELOG.md +62 -0
- {ollaagent-0.1.2 → ollaagent-0.1.4}/PKG-INFO +1 -1
- {ollaagent-0.1.2 → ollaagent-0.1.4}/ollaAgent/agent.py +21 -5
- {ollaagent-0.1.2 → ollaagent-0.1.4}/ollaAgent/memory.py +2 -2
- {ollaagent-0.1.2 → ollaagent-0.1.4}/pyproject.toml +1 -1
- {ollaagent-0.1.2 → ollaagent-0.1.4}/tests/test_agent.py +61 -9
- {ollaagent-0.1.2 → ollaagent-0.1.4}/tests/test_memory.py +17 -1
- {ollaagent-0.1.2 → ollaagent-0.1.4}/uv.lock +1 -1
- {ollaagent-0.1.2 → ollaagent-0.1.4}/.agentic_python/guidelines.md +0 -0
- {ollaagent-0.1.2 → ollaagent-0.1.4}/.agentic_python/reviewer_role.md +0 -0
- {ollaagent-0.1.2 → ollaagent-0.1.4}/.env.sample +0 -0
- {ollaagent-0.1.2 → ollaagent-0.1.4}/.gitignore +0 -0
- {ollaagent-0.1.2 → ollaagent-0.1.4}/.python-version +0 -0
- {ollaagent-0.1.2 → ollaagent-0.1.4}/AGENT.md +0 -0
- {ollaagent-0.1.2 → ollaagent-0.1.4}/CLAUDE.md +0 -0
- {ollaagent-0.1.2 → ollaagent-0.1.4}/LICENSE +0 -0
- {ollaagent-0.1.2 → ollaagent-0.1.4}/README.md +0 -0
- {ollaagent-0.1.2 → ollaagent-0.1.4}/README_ko.md +0 -0
- {ollaagent-0.1.2 → ollaagent-0.1.4}/main.py +0 -0
- {ollaagent-0.1.2 → ollaagent-0.1.4}/ollaAgent/__init__.py +0 -0
- {ollaagent-0.1.2 → ollaagent-0.1.4}/ollaAgent/config_loader.py +0 -0
- {ollaagent-0.1.2 → ollaagent-0.1.4}/ollaAgent/ollama_client.py +0 -0
- {ollaagent-0.1.2 → ollaagent-0.1.4}/ollaAgent/permissions.py +0 -0
- {ollaagent-0.1.2 → ollaagent-0.1.4}/ollaAgent/plan_mode.py +0 -0
- {ollaagent-0.1.2 → ollaagent-0.1.4}/ollaAgent/subagent.py +0 -0
- {ollaagent-0.1.2 → ollaagent-0.1.4}/ollaAgent/tool_bash.py +0 -0
- {ollaagent-0.1.2 → ollaagent-0.1.4}/tests/__init__.py +0 -0
- {ollaagent-0.1.2 → ollaagent-0.1.4}/tests/conftest.py +0 -0
- {ollaagent-0.1.2 → ollaagent-0.1.4}/tests/test_config_loader.py +0 -0
- {ollaagent-0.1.2 → ollaagent-0.1.4}/tests/test_permissions.py +0 -0
- {ollaagent-0.1.2 → ollaagent-0.1.4}/tests/test_plan_mode.py +0 -0
- {ollaagent-0.1.2 → ollaagent-0.1.4}/tests/test_subagent.py +0 -0
- {ollaagent-0.1.2 → ollaagent-0.1.4}/tests/test_tool_bash.py +0 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
|
6
|
+
Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## [0.1.4] - 2026-03-22
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `--version` / `-v` 플래그 지원 (`ollaagent --version`)
|
|
14
|
+
- `--model` / `-m` CLI 인수 지원 (config 기본값 오버라이드)
|
|
15
|
+
- `--host` CLI 인수 지원 (env/config보다 우선)
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## [0.1.3] - 2026-03-22
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- `tool_calls: null`, `message: null`, `content: null`, `thinking: null` 등 실제 API 응답의 null 필드로 인한 `TypeError` 방어 처리 (`agent.py`)
|
|
23
|
+
- `entries: null`, `messages: null` JSON 필드로 인한 memory 로드 오류 방어 처리 (`memory.py`)
|
|
24
|
+
|
|
25
|
+
### Tests
|
|
26
|
+
- `_make_chunk` 헬퍼에 `null_*` 플래그 추가 — 실제 API null 응답 재현 가능
|
|
27
|
+
- null API 응답 엣지 케이스 5종 추가 (138 → 143 tests)
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## [0.1.2] - 2026-03-22
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
- `_accumulate_tool_calls`에서 `tool_calls` 키가 존재하지만 값이 `null`인 경우 `TypeError` 발생 수정
|
|
35
|
+
- `msg.get("tool_calls", [])` → `msg.get("tool_calls") or []`
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## [0.1.1] - 2026-03-22
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
- `AgentConfig`에 `ollama_host`, `cf_access_client_id`, `cf_access_client_secret` 필드 추가
|
|
43
|
+
- `~/.agents/config.yaml`만으로 `.env` 없이 원격 ollama 서버 접속 가능
|
|
44
|
+
- `.env.sample` 생성 (용도별 주석 포함)
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
- `OLLAMA_HOST` 하드코딩 제거 → `os.getenv("OLLAMA_HOST") or agent_config.ollama_host` 우선순위 적용
|
|
48
|
+
- README / README_ko 환경변수명 오류 수정: `CF_CLIENT_ID` → `CF_ACCESS_CLIENT_ID`
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## [0.1.0] - 2026-03-22
|
|
53
|
+
|
|
54
|
+
### Added
|
|
55
|
+
- 최초 릴리즈
|
|
56
|
+
- 에이전트 루프 (`run_python`, `run_bash`, `read_file`, `write_file`, `list_files`)
|
|
57
|
+
- JSON 기반 영구 메모리 (`/memory add/list/search/clear`)
|
|
58
|
+
- 플랜 모드 (`/plan`)
|
|
59
|
+
- 병렬 서브에이전트 (`/subagent`, `multiprocessing.Pool`)
|
|
60
|
+
- 권한 제어 (`allow/deny` 패턴)
|
|
61
|
+
- Cloudflare Access 헤더 지원
|
|
62
|
+
- YAML 계층 설정 (`global < project < local`)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import argparse
|
|
1
2
|
import glob as glob_module
|
|
2
3
|
import json
|
|
3
4
|
import os
|
|
@@ -8,6 +9,8 @@ from functools import partial
|
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from typing import Any, NamedTuple
|
|
10
11
|
|
|
12
|
+
__version__ = "0.1.4"
|
|
13
|
+
|
|
11
14
|
from dotenv import load_dotenv
|
|
12
15
|
from ollama import Client
|
|
13
16
|
from rich.console import Console
|
|
@@ -358,7 +361,7 @@ def _accumulate_tool_calls(msg: dict[str, Any], accumulated: dict[int, dict]) ->
|
|
|
358
361
|
idx = tc.get("index", len(accumulated))
|
|
359
362
|
if idx not in accumulated:
|
|
360
363
|
accumulated[idx] = {"name": "", "arguments": ""}
|
|
361
|
-
fn = tc.get("function"
|
|
364
|
+
fn = tc.get("function") or {}
|
|
362
365
|
accumulated[idx]["name"] += fn.get("name", "")
|
|
363
366
|
raw_args = fn.get("arguments", "")
|
|
364
367
|
if isinstance(raw_args, dict):
|
|
@@ -403,11 +406,11 @@ def _stream_response(stream: Any) -> tuple[str, str, dict[int, dict], int]:
|
|
|
403
406
|
|
|
404
407
|
with Live(console=console, refresh_per_second=10) as live:
|
|
405
408
|
for chunk in stream:
|
|
406
|
-
msg = chunk.get("message"
|
|
407
|
-
thinking = msg.get("thinking"
|
|
409
|
+
msg = chunk.get("message") or {}
|
|
410
|
+
thinking = msg.get("thinking") or ""
|
|
408
411
|
if thinking:
|
|
409
412
|
thinking_content += thinking
|
|
410
|
-
content = msg.get("content"
|
|
413
|
+
content = msg.get("content") or ""
|
|
411
414
|
if content:
|
|
412
415
|
assistant_content += content
|
|
413
416
|
live.update(Markdown(assistant_content))
|
|
@@ -638,14 +641,27 @@ def _auto_save_session(messages: list[dict]) -> None:
|
|
|
638
641
|
|
|
639
642
|
def main() -> None:
|
|
640
643
|
"""대화형 agentic loop 진입점."""
|
|
644
|
+
parser = argparse.ArgumentParser(prog="ollaagent", add_help=False)
|
|
645
|
+
parser.add_argument("--version", "-v", action="store_true", help="버전 출력")
|
|
646
|
+
parser.add_argument("--model", "-m", type=str, default=None)
|
|
647
|
+
parser.add_argument("--host", type=str, default=None)
|
|
648
|
+
args, _ = parser.parse_known_args()
|
|
649
|
+
|
|
650
|
+
if args.version:
|
|
651
|
+
print(f"ollaagent {__version__}")
|
|
652
|
+
return
|
|
653
|
+
|
|
641
654
|
agent_config = load_config()
|
|
642
655
|
console.print(
|
|
643
656
|
f"[dim][Config] model={agent_config.model} | "
|
|
644
657
|
f"mode={agent_config.permission_mode.value} | "
|
|
645
658
|
f"threshold={agent_config.token_threshold:,}[/]"
|
|
646
659
|
)
|
|
660
|
+
if args.model:
|
|
661
|
+
agent_config.model = args.model
|
|
662
|
+
|
|
647
663
|
conn = ConnectionInfo(
|
|
648
|
-
host=os.getenv("OLLAMA_HOST") or agent_config.ollama_host,
|
|
664
|
+
host=args.host or os.getenv("OLLAMA_HOST") or agent_config.ollama_host,
|
|
649
665
|
cf_client_id=os.getenv("CF_ACCESS_CLIENT_ID")
|
|
650
666
|
or agent_config.cf_access_client_id,
|
|
651
667
|
cf_client_secret=os.getenv("CF_ACCESS_CLIENT_SECRET")
|
|
@@ -49,7 +49,7 @@ class SessionMemory:
|
|
|
49
49
|
try:
|
|
50
50
|
with self._path.open(encoding="utf-8") as fh:
|
|
51
51
|
data = json.load(fh)
|
|
52
|
-
self._entries = [MemoryEntry(**e) for e in data.get("entries"
|
|
52
|
+
self._entries = [MemoryEntry(**e) for e in (data.get("entries") or [])]
|
|
53
53
|
except (json.JSONDecodeError, KeyError, ValueError):
|
|
54
54
|
self._entries = []
|
|
55
55
|
|
|
@@ -125,6 +125,6 @@ def load_session(path: Path) -> list[dict]:
|
|
|
125
125
|
try:
|
|
126
126
|
with path.open(encoding="utf-8") as fh:
|
|
127
127
|
data = json.load(fh)
|
|
128
|
-
return data.get("messages"
|
|
128
|
+
return data.get("messages") or []
|
|
129
129
|
except (json.JSONDecodeError, KeyError):
|
|
130
130
|
return []
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
from unittest.mock import MagicMock, patch
|
|
2
2
|
|
|
3
|
-
import ollaAgent.agent as agent
|
|
4
3
|
import pytest
|
|
4
|
+
|
|
5
|
+
import ollaAgent.agent as agent
|
|
5
6
|
from ollaAgent.agent import (_is_model_available, _parse_subagent_input,
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
build_dispatch, execute_tool,
|
|
8
|
+
list_available_models, run_agentic_loop,
|
|
9
|
+
trim_by_tokens, trim_messages)
|
|
8
10
|
from ollaAgent.permissions import PermissionConfig, PermissionMode
|
|
9
11
|
|
|
10
12
|
_TEST_DISPATCH = build_dispatch(PermissionConfig(mode=PermissionMode.AUTO))
|
|
@@ -169,14 +171,28 @@ class TestTrimMessages:
|
|
|
169
171
|
|
|
170
172
|
|
|
171
173
|
def _make_chunk(
|
|
172
|
-
content="",
|
|
174
|
+
content="",
|
|
175
|
+
tool_calls=None,
|
|
176
|
+
thinking="",
|
|
177
|
+
done=False,
|
|
178
|
+
prompt_eval_count=0,
|
|
179
|
+
null_tool_calls=False,
|
|
180
|
+
null_message=False,
|
|
181
|
+
null_content=False,
|
|
182
|
+
null_thinking=False,
|
|
173
183
|
):
|
|
184
|
+
"""테스트용 stream chunk 생성.
|
|
185
|
+
|
|
186
|
+
null_* 플래그: 키는 존재하지만 값이 null인 실제 API 응답 재현용.
|
|
187
|
+
"""
|
|
188
|
+
if null_message:
|
|
189
|
+
return {"message": None}
|
|
174
190
|
msg = {}
|
|
175
|
-
if content
|
|
176
|
-
|
|
177
|
-
if
|
|
178
|
-
msg["
|
|
179
|
-
|
|
191
|
+
msg["content"] = None if null_content else (content or None)
|
|
192
|
+
msg["thinking"] = None if null_thinking else (thinking or None)
|
|
193
|
+
if null_tool_calls:
|
|
194
|
+
msg["tool_calls"] = None # 실제 API: "tool_calls": null
|
|
195
|
+
elif tool_calls:
|
|
180
196
|
msg["tool_calls"] = tool_calls
|
|
181
197
|
chunk = {"message": msg}
|
|
182
198
|
if done:
|
|
@@ -295,6 +311,42 @@ class TestAgenticLoop:
|
|
|
295
311
|
assert "최종 답변입니다." in result
|
|
296
312
|
assert "내부적으로 생각 중..." not in result
|
|
297
313
|
|
|
314
|
+
def test_null_tool_calls_in_stream_does_not_raise(self):
|
|
315
|
+
"""실제 API가 tool_calls: null 반환 시 TypeError 없이 정상 처리"""
|
|
316
|
+
mock_client = MagicMock()
|
|
317
|
+
mock_client.chat.return_value = _make_stream(
|
|
318
|
+
_make_chunk(null_tool_calls=True),
|
|
319
|
+
_make_chunk(content="응답입니다."),
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
messages = [{"role": "user", "content": "안녕"}]
|
|
323
|
+
result = run_agentic_loop(messages, mock_client, _TEST_DISPATCH)
|
|
324
|
+
assert "응답입니다." in result
|
|
325
|
+
|
|
326
|
+
def test_null_message_in_stream_does_not_raise(self):
|
|
327
|
+
"""실제 API가 message: null 반환 시 정상 처리"""
|
|
328
|
+
mock_client = MagicMock()
|
|
329
|
+
mock_client.chat.return_value = _make_stream(
|
|
330
|
+
_make_chunk(null_message=True),
|
|
331
|
+
_make_chunk(content="응답입니다."),
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
messages = [{"role": "user", "content": "안녕"}]
|
|
335
|
+
result = run_agentic_loop(messages, mock_client, _TEST_DISPATCH)
|
|
336
|
+
assert "응답입니다." in result
|
|
337
|
+
|
|
338
|
+
def test_null_content_and_thinking_in_stream_does_not_raise(self):
|
|
339
|
+
"""content, thinking이 null인 chunk 정상 처리"""
|
|
340
|
+
mock_client = MagicMock()
|
|
341
|
+
mock_client.chat.return_value = _make_stream(
|
|
342
|
+
_make_chunk(null_content=True, null_thinking=True),
|
|
343
|
+
_make_chunk(content="정상 응답."),
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
messages = [{"role": "user", "content": "테스트"}]
|
|
347
|
+
result = run_agentic_loop(messages, mock_client, _TEST_DISPATCH)
|
|
348
|
+
assert "정상 응답." in result
|
|
349
|
+
|
|
298
350
|
|
|
299
351
|
# ──────────────────────────────────────────
|
|
300
352
|
# Unit Tests: write_file
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import json
|
|
2
2
|
|
|
3
3
|
import pytest
|
|
4
|
-
|
|
4
|
+
|
|
5
|
+
from ollaAgent.memory import (MemoryEntry, SessionMemory, load_session,
|
|
6
|
+
save_session)
|
|
5
7
|
|
|
6
8
|
# ──────────────────────────────────────────
|
|
7
9
|
# Unit Tests: MemoryEntry
|
|
@@ -119,6 +121,13 @@ class TestSessionMemoryPersistence:
|
|
|
119
121
|
mem = SessionMemory(path=path)
|
|
120
122
|
assert mem.all() == []
|
|
121
123
|
|
|
124
|
+
def test_null_entries_key_gives_empty(self, tmp_path):
|
|
125
|
+
"""JSON에 entries: null 포함 시 빈 리스트로 처리"""
|
|
126
|
+
path = tmp_path / "null_entries.json"
|
|
127
|
+
path.write_text(json.dumps({"entries": None}))
|
|
128
|
+
mem = SessionMemory(path=path)
|
|
129
|
+
assert mem.all() == []
|
|
130
|
+
|
|
122
131
|
|
|
123
132
|
class TestSessionMemoryContextString:
|
|
124
133
|
|
|
@@ -174,6 +183,13 @@ class TestSaveLoadSession:
|
|
|
174
183
|
result = load_session(path)
|
|
175
184
|
assert result == []
|
|
176
185
|
|
|
186
|
+
def test_load_null_messages_key_returns_empty(self, tmp_path):
|
|
187
|
+
"""JSON에 messages: null 포함 시 빈 리스트 반환"""
|
|
188
|
+
path = tmp_path / "null_messages.json"
|
|
189
|
+
path.write_text(json.dumps({"messages": None}))
|
|
190
|
+
result = load_session(path)
|
|
191
|
+
assert result == []
|
|
192
|
+
|
|
177
193
|
def test_save_creates_parent_dirs(self, tmp_path):
|
|
178
194
|
path = tmp_path / "a" / "b" / "c" / "sess.json"
|
|
179
195
|
save_session([{"role": "user", "content": "x"}], path)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|