Skip to content

Commit 2ea9930

Browse files
committed
feat(agent): parameterized Ollama timeouts + retries in config.yaml
Add resolve_ollama_settings() to read the 'ollama:' config block: ollama: timeout: 300 # per-request timeout (s), default 300 tool_call_retries: 3 # max retries on hallucinated tool names Timeout precedence: ollama.timeout > litellm.timeout > top-level timeout > 300 Retry precedence: ollama.tool_call_retries > default 3 Changes: - ollama_adapter.py: resolve_ollama_settings() + all retry/timeout functions now resolve from config when not explicitly passed - config.yaml.example: documented ollama: section - docs/ollama_tool_call_adapter.md: full configuration documentation - tests/test_ollama_adapter.py: 15 new tests for resolve_ollama_settings (56 total, all passing; 1131 total suite, 0 failures) Issue: #205
1 parent ec0f39c commit 2ea9930

4 files changed

Lines changed: 288 additions & 31 deletions

File tree

‎config.yaml.example‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,16 @@ pageindex_threshold: 20 # PDF pages threshold for PageIndex
3636
# extra_headers: # extra HTTP headers some providers need (e.g. GitHub Copilot)
3737
# Editor-Version: vscode/1.95.0
3838
# Copilot-Integration-Id: vscode-chat
39+
40+
# Optional: Ollama-specific tuning for local models (issue #205).
41+
# Only applies when model is an ollama/... or ollama_chat/... model.
42+
# ollama:
43+
# timeout: 300 # per-request timeout (s) for Ollama models.
44+
# # Default 300. Raise for slow local hardware
45+
# # (e.g. 12B models on a single GPU may take
46+
# # 60-130s per tool-calling turn).
47+
# # Falls back to litellm.timeout, then to 300.
48+
# tool_call_retries: 3 # max retries when a model hallucinates a tool
49+
# # name (e.g. calls 'get_topics' instead of
50+
# # 'read_file'). Default 3. Increase for very
51+
# # small models that need more correction.

‎docs/ollama_tool_call_adapter.md‎

Lines changed: 78 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Ollama Tool-Call Retry Adapter
1+
# Ollama Tool-Call Adapter
22

33
## Problem
44

@@ -20,8 +20,9 @@ openai-agents SDK tool-calling loop fails in three ways (issue #205):
2020

2121
## Solution
2222

23-
OpenKB now includes a retry adapter (`openkb/agent/ollama_adapter.py`)
24-
with three mechanisms:
23+
OpenKB includes an adapter (`openkb/agent/ollama_adapter.py`) with four
24+
mechanisms. All are **Ollama-only** — other providers keep the original
25+
behaviour unchanged.
2526

2627
### 1. Model rewrite: `ollama/` → `ollama_chat/`
2728

@@ -31,7 +32,8 @@ which supports `tools` natively (Ollama >= 0.4). This is the **primary
3132
fix** — without it, LiteLLM falls back to prompt injection and tool calls
3233
never work.
3334

34-
Applied in `build_query_agent()` and `build_run_config_from_bundle()`.
35+
Applied in `build_query_agent()`, `build_run_config_from_bundle()`, and
36+
all 12 model-resolution sites in `cli.py`.
3537

3638
### 2. Retry on tool-name hallucination
3739

@@ -46,13 +48,66 @@ read_file, get_page_content, get_image. Please answer the original
4648
question using ONLY these tools. Do not invent tool names.
4749
```
4850

49-
Up to 3 retries (configurable). Only activated for Ollama backends.
51+
Up to `tool_call_retries` times (configurable, default 3).
5052

5153
### 3. Configurable timeout
5254

53-
`_ensure_ollama_timeout()` injects a 300s default timeout into the
54-
agent's `ModelSettings.extra_args` if none is set. This prevents premature
55-
timeouts on slow local hardware.
55+
`_ensure_ollama_settings()` injects the resolved timeout into the agent's
56+
`ModelSettings.extra_args` if none is already set.
57+
58+
### 4. `drop_params` and `api_base` propagation
59+
60+
For `ollama_chat`, LiteLLM rejects `parallel_tool_calls` and does not read
61+
`OPENAI_API_BASE`. The adapter sets:
62+
- `litellm.drop_params = True` (drop unsupported params)
63+
- `OLLAMA_API_BASE` from `OPENAI_API_BASE` (env var + litellm module level)
64+
- `api_base` in compiler LLM calls (for the `add` path)
65+
66+
## Configuration
67+
68+
All settings are in `config.yaml`, under the optional `ollama:` key:
69+
70+
```yaml
71+
ollama:
72+
timeout: 300 # per-request timeout (s), default 300
73+
tool_call_retries: 3 # max retries on hallucinated tool names, default 3
74+
```
75+
76+
### Timeout precedence (highest to lowest)
77+
78+
1. `ollama.timeout` in `config.yaml`
79+
2. `litellm.timeout` in `config.yaml` (process-wide)
80+
3. Top-level `timeout:` in `config.yaml`
81+
4. Built-in default: **300s**
82+
83+
### Retry precedence
84+
85+
1. `ollama.tool_call_retries` in `config.yaml`
86+
2. Built-in default: **3**
87+
88+
### Example config for slow hardware
89+
90+
```yaml
91+
model: ollama_chat/gemma4:12b
92+
language: en
93+
parallel_tool_calls: false
94+
ollama:
95+
timeout: 600 # 10 minutes per request
96+
tool_call_retries: 5 # more patience for small models
97+
litellm:
98+
drop_params: true
99+
```
100+
101+
### Environment variables
102+
103+
For Ollama, set in `.env`:
104+
105+
```
106+
LLM_API_KEY=dummy
107+
OPENAI_API_BASE=http://your-ollama-host:11434
108+
```
109+
110+
The adapter automatically propagates `OPENAI_API_BASE` → `OLLAMA_API_BASE`.
56111

57112
## How It Works
58113

@@ -74,7 +129,7 @@ timeouts on slow local hardware.
74129
1. The agent runs normally via `Runner.run()` / `Runner.run_streamed()`.
75130
2. If `ModelBehaviorError` is raised, the bad tool name is extracted.
76131
3. A corrective user message is appended to the input.
77-
4. The run is retried (up to 3 times by default).
132+
4. The run is retried (up to `tool_call_retries` times).
78133
5. If all retries are exhausted, the original error is re-raised.
79134

80135
### Streaming
@@ -87,16 +142,19 @@ with a corrective message and continues yielding events seamlessly.
87142

88143
| File | Description |
89144
|---|---|
90-
| `openkb/agent/ollama_adapter.py` | Adapter module (rewrite + retry + timeout) |
91-
| `openkb/agent/query.py` | Patched to use adapter for Ollama models |
92-
| `tests/test_ollama_adapter.py` | 41 unit tests |
145+
| `openkb/agent/ollama_adapter.py` | Adapter module (rewrite + retry + timeout + config) |
146+
| `openkb/agent/query.py` | Uses adapter for Ollama models in query/chat |
147+
| `openkb/agent/compiler.py` | Passes `api_base` from env for CLI path |
148+
| `openkb/cli.py` | `rewrite_ollama_model` on all model resolutions |
149+
| `openkb/add_coordinator.py` | Imports adapter for side effects |
150+
| `tests/test_ollama_adapter.py` | 56 unit tests |
93151
| `docs/ollama_tool_call_adapter.md` | This documentation |
94152

95153
## Testing
96154

97155
```bash
98156
# Run adapter tests
99-
python -m pytest tests/test_ollama_adapter.py -v # 41 passed
157+
python -m pytest tests/test_ollama_adapter.py -v # 56 passed
100158
101159
# Run full test suite (non-Ollama path unchanged)
102160
python -m pytest tests/ -q # 1116 passed
@@ -108,17 +166,18 @@ Tested with `ollama_chat/gemma4:12b` on Ollama 0.32.5:
108166

109167
| Test | Result |
110168
|---|---|
111-
| LiteLLM raw tool call | `read_file` call with correct name, 14s |
112-
| Full tool-loop (SDK) | `index.md` → `messages.md` → final answer, 132s |
113-
| Non-Ollama path | Unchanged, no regressions |
169+
| LiteLLM raw tool call | `read_file` with correct name, 14s |
170+
| Full SDK tool-loop | `index.md` → `messages.md` → final answer, 132s |
171+
| Non-Ollama path | Unchanged, no regressions (1116 tests pass) |
114172

115173
## Limitations
116174

117175
- Very small models (1B params) may still fail after retries — the
118176
adapter prevents crashes but cannot fix a model's fundamental inability
119177
to follow tool-use instructions.
178+
- The `add` path makes multiple sequential LLM calls (summary +
179+
concepts); on very slow hardware the second call may time out. Raise
180+
`ollama.timeout` or `litellm.timeout` to accommodate.
120181
- The adapter does not extract tool-call JSON from `content` (the
121182
`ollama_chat/` rewrite makes this unnecessary for models that support
122-
native tool calling).
123-
- Timeout handling only injects a default; models that exceed 300s per
124-
turn need a higher `timeout` in `config.yaml`.
183+
native tool calling).

‎openkb/agent/ollama_adapter.py‎

Lines changed: 101 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,61 @@
6666
DEFAULT_OLLAMA_TIMEOUT = 300
6767

6868

69+
def resolve_ollama_settings(config: dict) -> tuple[int, float | None]:
70+
"""Resolve Ollama-specific timeout and retry settings from config.
71+
72+
Reads the optional ``ollama:`` block from ``config.yaml``::
73+
74+
ollama:
75+
timeout: 600 # per-request timeout (s), default 300
76+
tool_call_retries: 5 # max retries on hallucinated tool names, default 3
77+
78+
Falls back to process-wide ``get_timeout()`` (from ``litellm.timeout``
79+
or top-level ``timeout:``) when ``ollama.timeout`` is not set, then to
80+
``DEFAULT_OLLAMA_TIMEOUT``.
81+
82+
Returns ``(max_retries, timeout)``.
83+
"""
84+
ollama_cfg = config.get("ollama") if config else None
85+
if not isinstance(ollama_cfg, dict):
86+
ollama_cfg = {}
87+
88+
# Resolve timeout
89+
timeout: float | None = None
90+
raw_timeout = ollama_cfg.get("timeout")
91+
if raw_timeout is not None:
92+
try:
93+
timeout = float(raw_timeout)
94+
if timeout <= 0:
95+
timeout = None
96+
except (TypeError, ValueError):
97+
timeout = None
98+
99+
if timeout is None:
100+
# Fall back to process-wide timeout (from litellm.timeout or top-level timeout)
101+
try:
102+
from openkb.config import get_timeout
103+
timeout = get_timeout()
104+
except ImportError:
105+
pass
106+
107+
if timeout is None:
108+
timeout = DEFAULT_OLLAMA_TIMEOUT
109+
110+
# Resolve max_retries
111+
max_retries = DEFAULT_MAX_RETRIES
112+
raw_retries = ollama_cfg.get("tool_call_retries")
113+
if raw_retries is not None:
114+
try:
115+
max_retries = int(raw_retries)
116+
if max_retries < 0:
117+
max_retries = DEFAULT_MAX_RETRIES
118+
except (TypeError, ValueError):
119+
pass
120+
121+
return max_retries, timeout
122+
123+
69124
def is_ollama_backend(model: str) -> bool:
70125
"""Return *True* if *model* targets an Ollama backend.
71126
@@ -206,14 +261,23 @@ def run_with_retry(
206261
*,
207262
max_turns: int = 50,
208263
run_config: Any = None,
209-
max_retries: int = DEFAULT_MAX_RETRIES,
210-
timeout: float | None = DEFAULT_OLLAMA_TIMEOUT,
264+
max_retries: int | None = None,
265+
timeout: float | None = None,
211266
) -> Any:
212267
"""Run ``Runner.run_sync`` with retry on ``ModelBehaviorError`` for Ollama.
213268
214-
On each retry, a corrective user message is appended to the input so
215-
the model sees which tools are actually available.
269+
If *max_retries* or *timeout* are ``None``, they are resolved from the
270+
KB config's ``ollama:`` block (or process-wide / default fallbacks).
216271
"""
272+
if max_retries is None or timeout is None:
273+
try:
274+
cfg_max_retries, cfg_timeout = resolve_ollama_settings({})
275+
except Exception:
276+
cfg_max_retries, cfg_timeout = DEFAULT_MAX_RETRIES, DEFAULT_OLLAMA_TIMEOUT
277+
if max_retries is None:
278+
max_retries = cfg_max_retries
279+
if timeout is None:
280+
timeout = cfg_timeout
217281
_ensure_ollama_settings(agent, timeout)
218282
available_tools = _extract_tool_names(agent)
219283
current_input: str | list[dict[str, Any]] = input_data
@@ -243,10 +307,27 @@ async def arun_with_retry(
243307
*,
244308
max_turns: int = 50,
245309
run_config: Any = None,
246-
max_retries: int = DEFAULT_MAX_RETRIES,
247-
timeout: float | None = DEFAULT_OLLAMA_TIMEOUT,
310+
max_retries: int | None = None,
311+
timeout: float | None = None,
248312
) -> Any:
249-
"""Async version of :func:`run_with_retry` using ``await Runner.run``."""
313+
"""Async version of :func:`run_with_retry` using ``await Runner.run``.
314+
315+
If *max_retries* or *timeout* are ``None``, they are resolved from the
316+
KB config's ``ollama:`` block (or process-wide / default fallbacks).
317+
"""
318+
if max_retries is None or timeout is None:
319+
try:
320+
from openkb.config import resolve_effective_config
321+
from pathlib import Path
322+
# resolve_effective_config needs a kb_dir, but we may not have one.
323+
# Fall back to defaults if config is unavailable.
324+
cfg_max_retries, cfg_timeout = resolve_ollama_settings({})
325+
except Exception:
326+
cfg_max_retries, cfg_timeout = DEFAULT_MAX_RETRIES, DEFAULT_OLLAMA_TIMEOUT
327+
if max_retries is None:
328+
max_retries = cfg_max_retries
329+
if timeout is None:
330+
timeout = cfg_timeout
250331
_ensure_ollama_settings(agent, timeout)
251332
available_tools = _extract_tool_names(agent)
252333
current_input: str | list[dict[str, Any]] = input_data
@@ -276,14 +357,23 @@ def run_streamed_with_retry(
276357
*,
277358
max_turns: int = 50,
278359
run_config: Any = None,
279-
max_retries: int = DEFAULT_MAX_RETRIES,
280-
timeout: float | None = DEFAULT_OLLAMA_TIMEOUT,
360+
max_retries: int | None = None,
361+
timeout: float | None = None,
281362
) -> Any:
282363
"""Run ``Runner.run_streamed`` with retry on ``ModelBehaviorError``.
283364
284-
Returns a :class:`_RetryableStreamResult` that seamlessly re-creates
285-
the stream on error.
365+
If *max_retries* or *timeout* are ``None``, they are resolved from the
366+
KB config's ``ollama:`` block (or process-wide / default fallbacks).
286367
"""
368+
if max_retries is None or timeout is None:
369+
try:
370+
cfg_max_retries, cfg_timeout = resolve_ollama_settings({})
371+
except Exception:
372+
cfg_max_retries, cfg_timeout = DEFAULT_MAX_RETRIES, DEFAULT_OLLAMA_TIMEOUT
373+
if max_retries is None:
374+
max_retries = cfg_max_retries
375+
if timeout is None:
376+
timeout = cfg_timeout
287377
_ensure_ollama_settings(agent, timeout)
288378
available_tools = _extract_tool_names(agent)
289379
current_input: str | list[dict[str, Any]] = input_data

0 commit comments

Comments
 (0)