Skip to content

Commit ea8db23

Browse files
committed
fix(agent): make parallel_tool_calls configurable; fixes Bedrock query/chat (#175)
query/chat agents hardcoded ModelSettings(parallel_tool_calls=False). On Amazon Bedrock Claude models this is fatal: LiteLLM's Bedrock Converse transform turns any parallel_tool_calls value into a tool_choice object carrying only `{"disable_parallel_tool_use": ...}` with no `type` field (converse_transformation.py: the _parallel_tool_use_config merge), which Bedrock rejects with "tool_choice.type: Field required" — so every query and chat fails. Confirmed unfixed through the latest litellm 1.91.1 (BerriAI/litellm#27184 still open); bumping doesn't help. Expose it as a config knob instead of hardcoding: - New `parallel_tool_calls` config key, default None = omit the setting (let the provider decide). Omitting is the only value that works on Bedrock, and is benign elsewhere (query tools are read-only; chat writes are path-restricted). - config.resolve_parallel_tool_calls() validates bool/null (warns on junk); set/get_parallel_tool_calls() process-wide stash, set by cli._setup_llm_key, read in build_query_agent — same mechanism as extra_headers/timeout. - Users can still force sequential tool calls with `parallel_tool_calls: false` on providers that support it. - Docs: config.yaml.example + configuration README (key table + a Bedrock setup section, which the repo previously lacked entirely). Closes #175
1 parent 5a2b48c commit ea8db23

8 files changed

Lines changed: 146 additions & 6 deletions

File tree

config.yaml.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ model: gpt-5.4 # LLM model (any LiteLLM-supported provider)
22
language: en # Wiki output language
33
pageindex_threshold: 20 # PDF pages threshold for PageIndex
44

5+
# Optional: whether query/chat agents may call tools in parallel. Omit (the
6+
# default) to let the provider decide. Leave it unset for Amazon Bedrock Claude
7+
# models — sending it makes LiteLLM emit a malformed tool_choice and every
8+
# query/chat fails. Set false to force sequential tool calls on other providers.
9+
# parallel_tool_calls: false
10+
511
# Optional: override the entity-type vocabulary used for entity pages.
612
# Omit this key to use the default 7 types
713
# (person, organization, place, product, work, event, other).

examples/configuration/README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ model: gpt-5.4 # LLM model (any LiteLLM-supported provider)
7070
language: en # Wiki output language
7171
pageindex_threshold: 20 # PDF pages threshold for PageIndex
7272

73+
# Optional: whether query/chat agents may call tools in parallel. Omit (the
74+
# default) to let the provider decide. Leave it unset for Amazon Bedrock Claude
75+
# models — sending it makes LiteLLM emit a malformed tool_choice and every
76+
# query/chat fails. Set false to force sequential tool calls on other providers.
77+
# parallel_tool_calls: false
78+
7379
# Optional: override the entity-type vocabulary used for entity pages.
7480
# Omit this key to use the default 7 types
7581
# (person, organization, place, product, work, event, other).
@@ -95,6 +101,7 @@ pageindex_threshold: 20 # PDF pages threshold for PageIndex
95101
| `model` | `gpt-5.4` | LLM used for all compile/query/chat work. |
96102
| `language` | `en` | Language the wiki is written in. |
97103
| `pageindex_threshold` | `20` | PDFs with this many pages **or more** take the long-doc (PageIndex) path; shorter ones go through the short-doc path. See [`pageindex-cloud/`](../pageindex-cloud/). |
104+
| `parallel_tool_calls` | *(unset)* | Whether query/chat agents may call tools in parallel. Unset = provider default. **Leave unset for Amazon Bedrock** (see below). Set `false` to force sequential tool calls elsewhere. |
98105
| `entity_types` | 7 defaults | Custom vocabulary for entity pages. `other` is always kept. |
99106
| `litellm:` || A pass-through block for LiteLLM. See below. |
100107

@@ -177,6 +184,24 @@ LLM_API_KEY=your-key-here
177184
won't warn about a missing one.
178185
- **PageIndex Cloud** uses a separate `PAGEINDEX_API_KEY` (see
179186
[`pageindex-cloud/`](../pageindex-cloud/)).
187+
- **Amazon Bedrock** (`model: bedrock/...`) authenticates with AWS credentials,
188+
not `LLM_API_KEY`. Put them in `<kb>/.env` (LiteLLM/boto3 read them from the
189+
environment); `LLM_API_KEY` isn't needed:
190+
191+
```bash
192+
# <kb>/.env
193+
AWS_ACCESS_KEY_ID=...
194+
AWS_SECRET_ACCESS_KEY=...
195+
AWS_REGION_NAME=eu-central-1
196+
```
197+
198+
```yaml
199+
# <kb>/.openkb/config.yaml
200+
model: bedrock/eu.anthropic.claude-sonnet-4-6
201+
# Do NOT set parallel_tool_calls for Bedrock Claude — leaving it unset (the
202+
# default) is what keeps query/chat working; setting it makes LiteLLM send a
203+
# malformed tool_choice that Bedrock rejects (issue #175).
204+
```
180205

181206
**Where keys are read from** (first match wins, existing env always respected):
182207

openkb/agent/query.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
read_wiki_image,
1313
write_kb_file,
1414
)
15-
from openkb.config import get_extra_headers, get_timeout_extra_args
15+
from openkb.config import get_extra_headers, get_parallel_tool_calls, get_timeout_extra_args
1616
from openkb.schema import get_agents_md
1717

1818
MAX_TURNS = 50
@@ -96,7 +96,7 @@ def get_image(image_path: str) -> ToolOutputImage | ToolOutputText:
9696
tools=[read_file, get_page_content, get_image],
9797
model=f"litellm/{model}",
9898
model_settings=ModelSettings(
99-
parallel_tool_calls=False,
99+
parallel_tool_calls=get_parallel_tool_calls(),
100100
extra_headers=get_extra_headers() or None,
101101
extra_args=get_timeout_extra_args(),
102102
),

openkb/cli.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ def filter(self, record: logging.LogRecord) -> bool:
5656
register_kb,
5757
resolve_extra_headers,
5858
set_extra_headers,
59+
resolve_parallel_tool_calls,
60+
set_parallel_tool_calls,
5961
resolve_timeout,
6062
set_timeout,
6163
resolve_litellm_settings,
@@ -174,6 +176,7 @@ def _setup_llm_key(kb_dir: Path | None = None) -> None:
174176
provider: str | None = None
175177
extra_headers: dict[str, str] = {}
176178
timeout: float | None = None
179+
parallel_tool_calls: bool | None = None
177180
litellm_settings: dict = {}
178181
if kb_dir is not None:
179182
config_path = kb_dir / ".openkb" / "config.yaml"
@@ -183,6 +186,7 @@ def _setup_llm_key(kb_dir: Path | None = None) -> None:
183186
provider = _extract_provider(str(model))
184187
extra_headers = resolve_extra_headers(config)
185188
timeout = resolve_timeout(config)
189+
parallel_tool_calls = resolve_parallel_tool_calls(config)
186190
litellm_settings = resolve_litellm_settings(config)
187191
# `timeout` / `extra_headers` in the block route to the per-call
188192
# stashes (replacing the legacy top-level keys); the rest are globals.
@@ -194,6 +198,7 @@ def _setup_llm_key(kb_dir: Path | None = None) -> None:
194198
timeout = resolve_timeout({"timeout": litellm_settings.pop("timeout")})
195199
set_extra_headers(extra_headers)
196200
set_timeout(timeout)
201+
set_parallel_tool_calls(parallel_tool_calls)
197202
_apply_litellm_settings(litellm_settings)
198203

199204
if not api_key:

openkb/config.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@
1717
"model": "gpt-5.4",
1818
"language": "en",
1919
"pageindex_threshold": 20,
20+
# Whether query/chat agents may call tools in parallel. None (default) omits
21+
# the setting so the provider decides — required for Amazon Bedrock's Claude
22+
# models, where sending parallel_tool_calls makes LiteLLM emit a malformed
23+
# tool_choice (missing `type`) and every query/chat fails (issue #175). Set
24+
# false to force sequential tool calls (fine on OpenAI/Anthropic-direct).
25+
"parallel_tool_calls": None,
2026
}
2127

2228
# Default entity-type vocabulary. Overridable per-KB via the optional
@@ -144,6 +150,26 @@ def resolve_extra_headers(config: dict) -> dict[str, str]:
144150
return headers
145151

146152

153+
def resolve_parallel_tool_calls(config: dict) -> bool | None:
154+
"""Resolve the optional ``parallel_tool_calls:`` key.
155+
156+
Returns ``None`` (omit the setting — provider default) when absent or
157+
explicitly null; ``True``/``False`` when set to a bool. A non-bool value is
158+
invalid and treated as unset, with a warning. ``None`` is the normal
159+
"unset" case and warns silently, matching ``resolve_timeout``.
160+
"""
161+
value = config.get("parallel_tool_calls")
162+
if value is None:
163+
return None
164+
if not isinstance(value, bool):
165+
logger.warning(
166+
"config: 'parallel_tool_calls' must be true or false, got %r — ignoring it.",
167+
value,
168+
)
169+
return None
170+
return value
171+
172+
147173
def resolve_timeout(config: dict) -> float | None:
148174
"""Resolve the optional ``timeout:`` key to a finite positive number of seconds.
149175
@@ -243,6 +269,24 @@ def get_timeout_extra_args() -> dict[str, float] | None:
243269
return {"timeout": _runtime_timeout} if _runtime_timeout is not None else None
244270

245271

272+
# Process-wide agent ``parallel_tool_calls`` setting, resolved from config by
273+
# the CLI (cli._setup_llm_key) and read when building query/chat agents. None =
274+
# omit the setting (provider default) — see the DEFAULT_CONFIG note on why
275+
# Bedrock needs this.
276+
_runtime_parallel_tool_calls: bool | None = None
277+
278+
279+
def set_parallel_tool_calls(value: bool | None) -> None:
280+
"""Set the process-wide agent ``parallel_tool_calls`` setting."""
281+
global _runtime_parallel_tool_calls
282+
_runtime_parallel_tool_calls = value
283+
284+
285+
def get_parallel_tool_calls() -> bool | None:
286+
"""Return the process-wide agent ``parallel_tool_calls`` setting (or None)."""
287+
return _runtime_parallel_tool_calls
288+
289+
246290
def load_config(config_path: Path) -> dict[str, Any]:
247291
"""Load YAML config from config_path, merged with DEFAULT_CONFIG.
248292

tests/conftest.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55

66
@pytest.fixture(autouse=True)
77
def _reset_extra_headers():
8-
"""Keep the process-wide LLM extra-headers / timeout stashes from leaking across tests."""
9-
from openkb.config import set_extra_headers, set_timeout
8+
"""Keep the process-wide LLM extra-headers / timeout / parallel-tool-calls
9+
stashes from leaking across tests."""
10+
from openkb.config import set_extra_headers, set_parallel_tool_calls, set_timeout
1011

1112
yield
1213
set_extra_headers({})
1314
set_timeout(None)
15+
set_parallel_tool_calls(None)
1416

1517

1618
@pytest.fixture

tests/test_config.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,55 @@
33
from openkb.config import (
44
DEFAULT_CONFIG,
55
get_extra_headers,
6+
get_parallel_tool_calls,
67
get_timeout,
78
load_config,
89
resolve_extra_headers,
910
resolve_litellm_settings,
11+
resolve_parallel_tool_calls,
1012
resolve_timeout,
1113
save_config,
1214
set_extra_headers,
15+
set_parallel_tool_calls,
1316
set_timeout,
1417
)
1518

1619

20+
def test_parallel_tool_calls_defaults_to_none():
21+
assert DEFAULT_CONFIG["parallel_tool_calls"] is None
22+
23+
24+
def test_resolve_parallel_tool_calls_absent_is_none():
25+
assert resolve_parallel_tool_calls({}) is None
26+
27+
28+
def test_resolve_parallel_tool_calls_explicit_bools():
29+
assert resolve_parallel_tool_calls({"parallel_tool_calls": True}) is True
30+
assert resolve_parallel_tool_calls({"parallel_tool_calls": False}) is False
31+
32+
33+
def test_resolve_parallel_tool_calls_none_is_silent(caplog):
34+
with caplog.at_level(logging.WARNING, logger="openkb.config"):
35+
assert resolve_parallel_tool_calls({"parallel_tool_calls": None}) is None
36+
assert caplog.text == ""
37+
38+
39+
def test_resolve_parallel_tool_calls_rejects_non_bool(caplog):
40+
# A non-bool (e.g. a string or int) is invalid → treat as unset, with a warning.
41+
with caplog.at_level(logging.WARNING, logger="openkb.config"):
42+
assert resolve_parallel_tool_calls({"parallel_tool_calls": "true"}) is None
43+
assert "parallel_tool_calls" in caplog.text
44+
45+
46+
def test_parallel_tool_calls_stash_roundtrip():
47+
set_parallel_tool_calls(False)
48+
assert get_parallel_tool_calls() is False
49+
set_parallel_tool_calls(True)
50+
assert get_parallel_tool_calls() is True
51+
set_parallel_tool_calls(None)
52+
assert get_parallel_tool_calls() is None
53+
54+
1755
def test_default_config_keys():
1856
assert "model" in DEFAULT_CONFIG
1957
assert "language" in DEFAULT_CONFIG

tests/test_query.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,14 +154,34 @@ def test_extra_headers_applied_from_stash(self, tmp_path):
154154
set_extra_headers({"Editor-Version": "vscode/1.95.0"})
155155
agent = build_query_agent(str(tmp_path), "github_copilot/gpt-5-mini")
156156
assert agent.model_settings.extra_headers == {"Editor-Version": "vscode/1.95.0"}
157-
# Existing settings are preserved.
158-
assert agent.model_settings.parallel_tool_calls is False
159157

160158
def test_no_extra_headers_by_default(self, tmp_path):
161159
agent = build_query_agent(str(tmp_path), "gpt-4o-mini")
162160
assert agent.model_settings.extra_headers is None
163161

164162

163+
class TestQueryAgentParallelToolCalls:
164+
"""Config-driven parallel_tool_calls reaches the agents-SDK model settings.
165+
166+
Default is None (omit the param → provider default), which is what keeps
167+
Bedrock's Claude models working: passing parallel_tool_calls at all makes
168+
LiteLLM emit a tool_choice object missing the required `type` field (see
169+
issue #175). Users can still force sequential tool calls with
170+
`parallel_tool_calls: false`.
171+
"""
172+
173+
def test_omitted_by_default(self, tmp_path):
174+
agent = build_query_agent(str(tmp_path), "bedrock/eu.anthropic.claude-sonnet-4-6")
175+
assert agent.model_settings.parallel_tool_calls is None
176+
177+
def test_applied_from_stash(self, tmp_path):
178+
from openkb.config import set_parallel_tool_calls
179+
180+
set_parallel_tool_calls(False)
181+
agent = build_query_agent(str(tmp_path), "gpt-4o-mini")
182+
assert agent.model_settings.parallel_tool_calls is False
183+
184+
165185
class TestQueryAgentTimeout:
166186
"""Config-driven timeout reaches the agents-SDK model settings via extra_args.
167187

0 commit comments

Comments
 (0)