Skip to content

Commit 49503be

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/pageindex-max-concurrency
# Conflicts: # config.yaml.example # examples/configuration/README.md
2 parents 792e0b8 + 6774c59 commit 49503be

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
@@ -8,6 +8,16 @@ pageindex_threshold: 20 # PDF pages threshold for PageIndex
88
# large PDFs. Omit to let each stage apply its own default.
99
# concurrency: 5
1010

11+
# Optional: whether the LLM agents (query, chat, lint, skill) may call tools
12+
# in parallel. Leave it UNSET (commented out) to keep OpenKB's per-agent
13+
# defaults. Setting it applies the SAME value to every agent:
14+
# true allow parallel tool calls
15+
# false force sequential tool calls
16+
# null don't send the setting at all (use the provider default) — REQUIRED
17+
# for Amazon Bedrock Claude, which rejects the request when
18+
# parallel_tool_calls is sent at all (any value). See #175.
19+
# parallel_tool_calls: null
20+
1121
# Optional: override the entity-type vocabulary used for entity pages.
1222
# Omit this key to use the default 7 types
1323
# (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
@@ -76,6 +76,16 @@ pageindex_threshold: 20 # PDF pages threshold for PageIndex
7676
# large PDFs. Omit to let each stage apply its own default.
7777
# concurrency: 5
7878

79+
# Optional: whether the LLM agents (query, chat, lint, skill) may call tools
80+
# in parallel. Leave it UNSET (commented out) to keep OpenKB's per-agent
81+
# defaults. Setting it applies the SAME value to every agent:
82+
# true allow parallel tool calls
83+
# false force sequential tool calls
84+
# null don't send the setting at all (use the provider default) — REQUIRED
85+
# for Amazon Bedrock Claude, which rejects the request when
86+
# parallel_tool_calls is sent at all (any value). See #175.
87+
# parallel_tool_calls: null
88+
7989
# Optional: override the entity-type vocabulary used for entity pages.
8090
# Omit this key to use the default 7 types
8191
# (person, organization, place, product, work, event, other).
@@ -102,6 +112,7 @@ pageindex_threshold: 20 # PDF pages threshold for PageIndex
102112
| `language` | `en` | Language the wiki is written in. |
103113
| `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/). |
104114
| `concurrency` | `null` | Caps concurrent LLM calls OpenKB makes during ingest — both PageIndex's indexing of a long document and OpenKB's own concept/entity compilation. The two never run at once for the same document, so one setting covers both. Lower it if you hit provider rate limits or "too many open files" on large PDFs. `null` lets each stage apply its own default. |
115+
| `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). |
105116
| `entity_types` | 7 defaults | Custom vocabulary for entity pages. `other` is always kept. |
106117
| `litellm:` || A pass-through block for LiteLLM. See below. |
107118

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

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

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
@@ -57,6 +57,8 @@ def filter(self, record: logging.LogRecord) -> bool:
5757
resolve_concurrency,
5858
resolve_extra_headers,
5959
set_extra_headers,
60+
resolve_parallel_tool_calls,
61+
set_parallel_tool_calls,
6062
resolve_timeout,
6163
set_timeout,
6264
resolve_litellm_settings,
@@ -175,6 +177,8 @@ def _setup_llm_key(kb_dir: Path | None = None) -> None:
175177
provider: str | None = None
176178
extra_headers: dict[str, str] = {}
177179
timeout: float | None = None
180+
parallel_tool_calls: bool | None = None
181+
parallel_tool_calls_explicit = False
178182
litellm_settings: dict = {}
179183
if kb_dir is not None:
180184
config_path = kb_dir / ".openkb" / "config.yaml"
@@ -184,6 +188,7 @@ def _setup_llm_key(kb_dir: Path | None = None) -> None:
184188
provider = _extract_provider(str(model))
185189
extra_headers = resolve_extra_headers(config)
186190
timeout = resolve_timeout(config)
191+
parallel_tool_calls, parallel_tool_calls_explicit = resolve_parallel_tool_calls(config)
187192
litellm_settings = resolve_litellm_settings(config)
188193
# `timeout` / `extra_headers` in the block route to the per-call
189194
# stashes (replacing the legacy top-level keys); the rest are globals.
@@ -195,6 +200,7 @@ def _setup_llm_key(kb_dir: Path | None = None) -> None:
195200
timeout = resolve_timeout({"timeout": litellm_settings.pop("timeout")})
196201
set_extra_headers(extra_headers)
197202
set_timeout(timeout)
203+
set_parallel_tool_calls(parallel_tool_calls, parallel_tool_calls_explicit)
198204
_apply_litellm_settings(litellm_settings)
199205

200206
if not api_key:

openkb/config.py

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

153153

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

276300

301+
# Process-wide agent ``parallel_tool_calls`` as ``(value, was_explicit)``, set
302+
# from config by the CLI and read when building agents. ``(None, False)`` = not
303+
# configured, so each agent falls back to its own default (resolve_model_settings).
304+
_runtime_parallel_tool_calls: tuple[bool | None, bool] = (None, False)
305+
306+
307+
def set_parallel_tool_calls(value: bool | None, was_explicit: bool) -> None:
308+
"""Set the process-wide ``parallel_tool_calls`` — see :func:`resolve_parallel_tool_calls`."""
309+
global _runtime_parallel_tool_calls
310+
_runtime_parallel_tool_calls = (value, was_explicit)
311+
312+
313+
def get_parallel_tool_calls() -> tuple[bool | None, bool]:
314+
"""Return the process-wide ``parallel_tool_calls`` as ``(value, was_explicit)``."""
315+
return _runtime_parallel_tool_calls
316+
317+
318+
def resolve_model_settings(*, default_parallel_tool_calls: bool | None = False) -> dict[str, Any]:
319+
"""Assemble the agents-SDK ``ModelSettings`` kwargs from the process-wide LLM
320+
runtime settings — the single place tool-using agent builders wire them in.
321+
322+
``default_parallel_tool_calls`` (the caller's own historical default) is used
323+
only when config didn't set ``parallel_tool_calls``; an explicit value always
324+
wins. Tool-less agents (skill-eval graders) skip this and omit the setting —
325+
the SDK forwards an explicit ``False`` even without tools, which strict
326+
OpenAI-compatible endpoints reject.
327+
"""
328+
value, was_explicit = get_parallel_tool_calls()
329+
parallel_tool_calls = value if was_explicit else default_parallel_tool_calls
330+
return {
331+
"extra_headers": get_extra_headers() or None,
332+
"extra_args": get_timeout_extra_args(),
333+
"parallel_tool_calls": parallel_tool_calls,
334+
}
335+
336+
277337
def load_config(config_path: Path) -> dict[str, Any]:
278338
"""Load YAML config from config_path, merged with DEFAULT_CONFIG.
279339

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,17 +3,126 @@
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_concurrency,
910
resolve_extra_headers,
1011
resolve_litellm_settings,
12+
resolve_model_settings,
13+
resolve_parallel_tool_calls,
1114
resolve_timeout,
1215
save_config,
1316
set_extra_headers,
17+
set_parallel_tool_calls,
1418
set_timeout,
1519
)
1620

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

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

0 commit comments

Comments
 (0)