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.
Files changed (33) hide show
  1. ollaagent-0.1.4/CHANGELOG.md +62 -0
  2. {ollaagent-0.1.2 → ollaagent-0.1.4}/PKG-INFO +1 -1
  3. {ollaagent-0.1.2 → ollaagent-0.1.4}/ollaAgent/agent.py +21 -5
  4. {ollaagent-0.1.2 → ollaagent-0.1.4}/ollaAgent/memory.py +2 -2
  5. {ollaagent-0.1.2 → ollaagent-0.1.4}/pyproject.toml +1 -1
  6. {ollaagent-0.1.2 → ollaagent-0.1.4}/tests/test_agent.py +61 -9
  7. {ollaagent-0.1.2 → ollaagent-0.1.4}/tests/test_memory.py +17 -1
  8. {ollaagent-0.1.2 → ollaagent-0.1.4}/uv.lock +1 -1
  9. {ollaagent-0.1.2 → ollaagent-0.1.4}/.agentic_python/guidelines.md +0 -0
  10. {ollaagent-0.1.2 → ollaagent-0.1.4}/.agentic_python/reviewer_role.md +0 -0
  11. {ollaagent-0.1.2 → ollaagent-0.1.4}/.env.sample +0 -0
  12. {ollaagent-0.1.2 → ollaagent-0.1.4}/.gitignore +0 -0
  13. {ollaagent-0.1.2 → ollaagent-0.1.4}/.python-version +0 -0
  14. {ollaagent-0.1.2 → ollaagent-0.1.4}/AGENT.md +0 -0
  15. {ollaagent-0.1.2 → ollaagent-0.1.4}/CLAUDE.md +0 -0
  16. {ollaagent-0.1.2 → ollaagent-0.1.4}/LICENSE +0 -0
  17. {ollaagent-0.1.2 → ollaagent-0.1.4}/README.md +0 -0
  18. {ollaagent-0.1.2 → ollaagent-0.1.4}/README_ko.md +0 -0
  19. {ollaagent-0.1.2 → ollaagent-0.1.4}/main.py +0 -0
  20. {ollaagent-0.1.2 → ollaagent-0.1.4}/ollaAgent/__init__.py +0 -0
  21. {ollaagent-0.1.2 → ollaagent-0.1.4}/ollaAgent/config_loader.py +0 -0
  22. {ollaagent-0.1.2 → ollaagent-0.1.4}/ollaAgent/ollama_client.py +0 -0
  23. {ollaagent-0.1.2 → ollaagent-0.1.4}/ollaAgent/permissions.py +0 -0
  24. {ollaagent-0.1.2 → ollaagent-0.1.4}/ollaAgent/plan_mode.py +0 -0
  25. {ollaagent-0.1.2 → ollaagent-0.1.4}/ollaAgent/subagent.py +0 -0
  26. {ollaagent-0.1.2 → ollaagent-0.1.4}/ollaAgent/tool_bash.py +0 -0
  27. {ollaagent-0.1.2 → ollaagent-0.1.4}/tests/__init__.py +0 -0
  28. {ollaagent-0.1.2 → ollaagent-0.1.4}/tests/conftest.py +0 -0
  29. {ollaagent-0.1.2 → ollaagent-0.1.4}/tests/test_config_loader.py +0 -0
  30. {ollaagent-0.1.2 → ollaagent-0.1.4}/tests/test_permissions.py +0 -0
  31. {ollaagent-0.1.2 → ollaagent-0.1.4}/tests/test_plan_mode.py +0 -0
  32. {ollaagent-0.1.2 → ollaagent-0.1.4}/tests/test_subagent.py +0 -0
  33. {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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ollaagent
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Local LLM agent powered by ollama — memory, plan mode, subagents
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.11
@@ -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,6 +1,6 @@
1
1
  [project]
2
2
  name = "ollaagent"
3
- version = "0.1.2"
3
+ version = "0.1.4"
4
4
  description = "Local LLM agent powered by ollama — memory, plan mode, subagents"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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
- build_dispatch, execute_tool, list_available_models,
7
- run_agentic_loop, trim_by_tokens, trim_messages)
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="", tool_calls=None, thinking="", done=False, prompt_eval_count=0
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
- msg["content"] = content
177
- if thinking:
178
- msg["thinking"] = thinking
179
- if tool_calls:
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
- from ollaAgent.memory import MemoryEntry, SessionMemory, load_session, save_session
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)
@@ -187,7 +187,7 @@ wheels = [
187
187
 
188
188
  [[package]]
189
189
  name = "ollaagent"
190
- version = "0.1.1"
190
+ version = "0.1.3"
191
191
  source = { editable = "." }
192
192
  dependencies = [
193
193
  { name = "ollama" },
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