Skip to content

Commit 6774c59

Browse files
fix(agent): make parallel_tool_calls configurable to fix Amazon Bedrock query/chat (#175) (#177)
* fix(agent): make parallel_tool_calls configurable; fixes Bedrock query/chat (#175)
1 parent 5a2b48c commit 6774c59

14 files changed

Lines changed: 420 additions & 32 deletions

config.yaml.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@ 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 the LLM agents (query, chat, lint, skill) may call tools
6+
# in parallel. Leave it UNSET (commented out) to keep OpenKB's per-agent
7+
# defaults. Setting it applies the SAME value to every agent:
8+
# true allow parallel tool calls
9+
# false force sequential tool calls
10+
# null don't send the setting at all (use the provider default) — REQUIRED
11+
# for Amazon Bedrock Claude, which rejects the request when
12+
# parallel_tool_calls is sent at all (any value). See #175.
13+
# parallel_tool_calls: null
14+
515
# Optional: override the entity-type vocabulary used for entity pages.
616
# Omit this key to use the default 7 types
717
# (person, organization, place, product, work, event, other).

examples/configuration/README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,16 @@ 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 the LLM agents (query, chat, lint, skill) may call tools
74+
# in parallel. Leave it UNSET (commented out) to keep OpenKB's per-agent
75+
# defaults. Setting it applies the SAME value to every agent:
76+
# true allow parallel tool calls
77+
# false force sequential tool calls
78+
# null don't send the setting at all (use the provider default) — REQUIRED
79+
# for Amazon Bedrock Claude, which rejects the request when
80+
# parallel_tool_calls is sent at all (any value). See #175.
81+
# parallel_tool_calls: null
82+
7383
# Optional: override the entity-type vocabulary used for entity pages.
7484
# Omit this key to use the default 7 types
7585
# (person, organization, place, product, work, event, other).
@@ -95,6 +105,7 @@ pageindex_threshold: 20 # PDF pages threshold for PageIndex
95105
| `model` | `gpt-5.4` | LLM used for all compile/query/chat work. |
96106
| `language` | `en` | Language the wiki is written in. |
97107
| `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/). |
108+
| `parallel_tool_calls` | unset | Whether the LLM agents (query, chat, lint, skill) may call tools in parallel. Unset keeps OpenKB's per-agent defaults; `true`/`false` force allow/sequential for every agent; `null` omits the setting (provider default). **Amazon Bedrock needs `null`** (see below). |
98109
| `entity_types` | 7 defaults | Custom vocabulary for entity pages. `other` is always kept. |
99110
| `litellm:` || A pass-through block for LiteLLM. See below. |
100111

@@ -177,6 +188,26 @@ LLM_API_KEY=your-key-here
177188
won't warn about a missing one.
178189
- **PageIndex Cloud** uses a separate `PAGEINDEX_API_KEY` (see
179190
[`pageindex-cloud/`](../pageindex-cloud/)).
191+
- **Amazon Bedrock** (`model: bedrock/...`) authenticates with AWS credentials,
192+
not `LLM_API_KEY`. Put them in `<kb>/.env` (LiteLLM/boto3 read them from the
193+
environment); `LLM_API_KEY` isn't needed:
194+
195+
```bash
196+
# <kb>/.env
197+
AWS_ACCESS_KEY_ID=...
198+
AWS_SECRET_ACCESS_KEY=...
199+
AWS_REGION_NAME=eu-central-1
200+
```
201+
202+
```yaml
203+
# <kb>/.openkb/config.yaml
204+
model: bedrock/eu.anthropic.claude-sonnet-4-6
205+
parallel_tool_calls: null # REQUIRED for Bedrock Claude: sending
206+
# parallel_tool_calls at all (any value) makes
207+
# LiteLLM send a malformed tool_choice that Bedrock
208+
# rejects (#175). null tells OpenKB to omit it.
209+
# Write it as bare `null` — not `None` or "null".
210+
```
180211

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

openkb/agent/linter.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from agents.model_settings import ModelSettings
99

1010
from openkb.agent.tools import list_wiki_files, read_wiki_file
11-
from openkb.config import get_extra_headers, get_timeout_extra_args
11+
from openkb.config import resolve_model_settings
1212
from openkb.schema import get_agents_md
1313

1414
MAX_TURNS = 50
@@ -82,10 +82,7 @@ def read_file(path: str) -> str:
8282
instructions=instructions,
8383
tools=[list_files, read_file],
8484
model=f"litellm/{model}",
85-
model_settings=ModelSettings(
86-
extra_headers=get_extra_headers() or None,
87-
extra_args=get_timeout_extra_args(),
88-
),
85+
model_settings=ModelSettings(**resolve_model_settings(default_parallel_tool_calls=None)),
8986
)
9087

9188

openkb/agent/query.py

Lines changed: 2 additions & 6 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 resolve_model_settings
1616
from openkb.schema import get_agents_md
1717

1818
MAX_TURNS = 50
@@ -95,11 +95,7 @@ def get_image(image_path: str) -> ToolOutputImage | ToolOutputText:
9595
instructions=instructions,
9696
tools=[read_file, get_page_content, get_image],
9797
model=f"litellm/{model}",
98-
model_settings=ModelSettings(
99-
parallel_tool_calls=False,
100-
extra_headers=get_extra_headers() or None,
101-
extra_args=get_timeout_extra_args(),
102-
),
98+
model_settings=ModelSettings(**resolve_model_settings()),
10399
)
104100

105101

openkb/cli.py

Lines changed: 6 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,8 @@ 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
180+
parallel_tool_calls_explicit = False
177181
litellm_settings: dict = {}
178182
if kb_dir is not None:
179183
config_path = kb_dir / ".openkb" / "config.yaml"
@@ -183,6 +187,7 @@ def _setup_llm_key(kb_dir: Path | None = None) -> None:
183187
provider = _extract_provider(str(model))
184188
extra_headers = resolve_extra_headers(config)
185189
timeout = resolve_timeout(config)
190+
parallel_tool_calls, parallel_tool_calls_explicit = resolve_parallel_tool_calls(config)
186191
litellm_settings = resolve_litellm_settings(config)
187192
# `timeout` / `extra_headers` in the block route to the per-call
188193
# stashes (replacing the legacy top-level keys); the rest are globals.
@@ -194,6 +199,7 @@ def _setup_llm_key(kb_dir: Path | None = None) -> None:
194199
timeout = resolve_timeout({"timeout": litellm_settings.pop("timeout")})
195200
set_extra_headers(extra_headers)
196201
set_timeout(timeout)
202+
set_parallel_tool_calls(parallel_tool_calls, parallel_tool_calls_explicit)
197203
_apply_litellm_settings(litellm_settings)
198204

199205
if not api_key:

openkb/config.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,30 @@ def resolve_extra_headers(config: dict) -> dict[str, str]:
144144
return headers
145145

146146

147+
def resolve_parallel_tool_calls(config: dict) -> tuple[bool | None, bool]:
148+
"""Resolve the optional ``parallel_tool_calls:`` key to ``(value, was_explicit)``.
149+
150+
Absent → ``(None, False)`` so each agent applies its own default (see
151+
``resolve_model_settings``). ``true``/``false`` → that bool; explicit
152+
``null`` → ``None`` (omit — the Amazon Bedrock #175 escape hatch); both with
153+
``was_explicit=True`` so they override every agent uniformly. An invalid
154+
value degrades to omit (never breaks a provider), with a warning.
155+
"""
156+
if "parallel_tool_calls" not in config:
157+
return None, False
158+
value = config["parallel_tool_calls"]
159+
if value is None:
160+
return None, True
161+
if isinstance(value, bool):
162+
return value, True
163+
logger.warning(
164+
"config: 'parallel_tool_calls' must be true, false, or null, got %r "
165+
"— omitting the setting.",
166+
value,
167+
)
168+
return None, True
169+
170+
147171
def resolve_timeout(config: dict) -> float | None:
148172
"""Resolve the optional ``timeout:`` key to a finite positive number of seconds.
149173
@@ -243,6 +267,42 @@ def get_timeout_extra_args() -> dict[str, float] | None:
243267
return {"timeout": _runtime_timeout} if _runtime_timeout is not None else None
244268

245269

270+
# Process-wide agent ``parallel_tool_calls`` as ``(value, was_explicit)``, set
271+
# from config by the CLI and read when building agents. ``(None, False)`` = not
272+
# configured, so each agent falls back to its own default (resolve_model_settings).
273+
_runtime_parallel_tool_calls: tuple[bool | None, bool] = (None, False)
274+
275+
276+
def set_parallel_tool_calls(value: bool | None, was_explicit: bool) -> None:
277+
"""Set the process-wide ``parallel_tool_calls`` — see :func:`resolve_parallel_tool_calls`."""
278+
global _runtime_parallel_tool_calls
279+
_runtime_parallel_tool_calls = (value, was_explicit)
280+
281+
282+
def get_parallel_tool_calls() -> tuple[bool | None, bool]:
283+
"""Return the process-wide ``parallel_tool_calls`` as ``(value, was_explicit)``."""
284+
return _runtime_parallel_tool_calls
285+
286+
287+
def resolve_model_settings(*, default_parallel_tool_calls: bool | None = False) -> dict[str, Any]:
288+
"""Assemble the agents-SDK ``ModelSettings`` kwargs from the process-wide LLM
289+
runtime settings — the single place tool-using agent builders wire them in.
290+
291+
``default_parallel_tool_calls`` (the caller's own historical default) is used
292+
only when config didn't set ``parallel_tool_calls``; an explicit value always
293+
wins. Tool-less agents (skill-eval graders) skip this and omit the setting —
294+
the SDK forwards an explicit ``False`` even without tools, which strict
295+
OpenAI-compatible endpoints reject.
296+
"""
297+
value, was_explicit = get_parallel_tool_calls()
298+
parallel_tool_calls = value if was_explicit else default_parallel_tool_calls
299+
return {
300+
"extra_headers": get_extra_headers() or None,
301+
"extra_args": get_timeout_extra_args(),
302+
"parallel_tool_calls": parallel_tool_calls,
303+
}
304+
305+
246306
def load_config(config_path: Path) -> dict[str, Any]:
247307
"""Load YAML config from config_path, merged with DEFAULT_CONFIG.
248308

openkb/skill/creator.py

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from agents import Agent, Runner, ToolOutputImage, ToolOutputText, function_tool
2121
from agents.model_settings import ModelSettings
2222

23-
from openkb.config import get_extra_headers, get_timeout_extra_args
23+
from openkb.config import resolve_model_settings
2424
from openkb.prompts import load_prompt
2525
from openkb.schema import get_agents_md
2626
from openkb.skill import skill_dir
@@ -154,18 +154,14 @@ def done(summary: str) -> str:
154154
done,
155155
],
156156
model=f"litellm/{model}",
157-
# Allow the model to issue multiple read tool calls in one turn —
158-
# the compile's early phase is a fan-out (list dir -> read N
159-
# summaries -> read N source page-ranges), and serialising each
160-
# read into its own turn costs roughly 5-10 extra round-trips per
161-
# compile. Writes serialise naturally because each
162-
# `write_skill_file` depends on accumulated reads; the model has
163-
# no reason to issue parallel writes to the same path.
164-
model_settings=ModelSettings(
165-
parallel_tool_calls=True,
166-
extra_headers=get_extra_headers() or None,
167-
extra_args=get_timeout_extra_args(),
168-
),
157+
# Default to allowing parallel read tool calls — the compile's early
158+
# phase is a fan-out (list dir -> read N summaries -> read N source
159+
# page-ranges), and serialising each read into its own turn costs
160+
# roughly 5-10 extra round-trips per compile. Writes serialise
161+
# naturally because each `write_skill_file` depends on accumulated
162+
# reads; the model has no reason to issue parallel writes to the
163+
# same path. An explicit config value (e.g. `null` for Bedrock) wins.
164+
model_settings=ModelSettings(**resolve_model_settings(default_parallel_tool_calls=True)),
169165
)
170166

171167

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, False)
1416

1517

1618
@pytest.fixture

tests/test_config.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,125 @@
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_model_settings,
12+
resolve_parallel_tool_calls,
1013
resolve_timeout,
1114
save_config,
1215
set_extra_headers,
16+
set_parallel_tool_calls,
1317
set_timeout,
1418
)
1519

20+
# --- parallel_tool_calls ------------------------------------------------------
21+
#
22+
# (value, was_explicit) distinguishes "not configured" (each agent uses its own
23+
# default) from an explicit true/false/null (overrides every agent uniformly).
24+
25+
26+
def test_parallel_tool_calls_not_in_default_config():
27+
# No single default fits every agent (see module docstring above), so this
28+
# key is intentionally absent from DEFAULT_CONFIG — load_config's merge
29+
# must not mask "the user's config.yaml doesn't mention this key".
30+
assert "parallel_tool_calls" not in DEFAULT_CONFIG
31+
32+
33+
def test_resolve_parallel_tool_calls_absent_is_unset():
34+
assert resolve_parallel_tool_calls({}) == (None, False)
35+
36+
37+
def test_resolve_parallel_tool_calls_explicit_bools():
38+
assert resolve_parallel_tool_calls({"parallel_tool_calls": True}) == (True, True)
39+
assert resolve_parallel_tool_calls({"parallel_tool_calls": False}) == (False, True)
40+
41+
42+
def test_resolve_parallel_tool_calls_null_means_omit(caplog):
43+
# Explicit null = "don't send the param" (provider default). This is the
44+
# escape hatch for Amazon Bedrock, and is silent (not an invalid value) —
45+
# explicit and distinct from "absent" even though both currently carry a
46+
# value of None; was_explicit is what tells them apart.
47+
with caplog.at_level(logging.WARNING, logger="openkb.config"):
48+
assert resolve_parallel_tool_calls({"parallel_tool_calls": None}) == (None, True)
49+
assert caplog.text == ""
50+
51+
52+
def test_resolve_parallel_tool_calls_rejects_non_bool(caplog):
53+
# An invalid value (not true/false/null) degrades to the one value known
54+
# to never break any provider — omit the setting — rather than to a fixed
55+
# bool that could reproduce the exact failure (e.g. Amazon Bedrock) the
56+
# user may have been trying to escape via this exact key.
57+
with caplog.at_level(logging.WARNING, logger="openkb.config"):
58+
assert resolve_parallel_tool_calls({"parallel_tool_calls": "true"}) == (None, True)
59+
assert "parallel_tool_calls" in caplog.text
60+
61+
62+
def test_parallel_tool_calls_stash_roundtrip():
63+
set_parallel_tool_calls(False, True)
64+
assert get_parallel_tool_calls() == (False, True)
65+
set_parallel_tool_calls(True, True)
66+
assert get_parallel_tool_calls() == (True, True)
67+
set_parallel_tool_calls(None, True)
68+
assert get_parallel_tool_calls() == (None, True)
69+
set_parallel_tool_calls(None, False)
70+
assert get_parallel_tool_calls() == (None, False)
71+
72+
73+
def test_parallel_tool_calls_stash_default_is_unset():
74+
# The raw stash default must mean "not configured", matching an absent key,
75+
# so an agent built before _setup_llm_key runs defers to its own default.
76+
set_parallel_tool_calls(None, False)
77+
assert get_parallel_tool_calls() == resolve_parallel_tool_calls({})
78+
79+
80+
# --- resolve_model_settings ---------------------------------------------------
81+
82+
83+
def test_resolve_model_settings_uses_own_default_when_unset():
84+
set_extra_headers({})
85+
set_timeout(None)
86+
set_parallel_tool_calls(None, False)
87+
assert resolve_model_settings() == {
88+
"extra_headers": None,
89+
"extra_args": None,
90+
"parallel_tool_calls": False, # the function's own default
91+
}
92+
assert resolve_model_settings(default_parallel_tool_calls=None) == {
93+
"extra_headers": None,
94+
"extra_args": None,
95+
"parallel_tool_calls": None,
96+
}
97+
assert resolve_model_settings(default_parallel_tool_calls=True) == {
98+
"extra_headers": None,
99+
"extra_args": None,
100+
"parallel_tool_calls": True,
101+
}
102+
103+
104+
def test_resolve_model_settings_explicit_value_overrides_every_default():
105+
# An explicit config choice always wins over whatever default a specific
106+
# caller would otherwise apply — the whole point of the escape hatch is
107+
# that it works uniformly, regardless of which agent is asking.
108+
set_extra_headers({"X-A": "1"})
109+
set_timeout(1200.0)
110+
set_parallel_tool_calls(None, True) # explicit null: omit, for everyone
111+
for default in (False, True, None):
112+
assert resolve_model_settings(default_parallel_tool_calls=default) == {
113+
"extra_headers": {"X-A": "1"},
114+
"extra_args": {"timeout": 1200.0},
115+
"parallel_tool_calls": None,
116+
}
117+
118+
set_parallel_tool_calls(True, True) # explicit true: allow parallel, for everyone
119+
for default in (False, True, None):
120+
assert (
121+
resolve_model_settings(default_parallel_tool_calls=default)["parallel_tool_calls"]
122+
is True
123+
)
124+
16125

17126
def test_default_config_keys():
18127
assert "model" in DEFAULT_CONFIG

0 commit comments

Comments
 (0)