Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/jumar/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,7 @@ def run_item(
invoked_as=judge_resolved.agent,
base_url=judge_resolved.base_url,
api_key_env=judge_resolved.api_key_env,
reasoning_effort=judge_resolved.reasoning_effort,
commands_allow=config.commands.allow,
commands_deny=config.commands.deny,
)
Expand Down
48 changes: 47 additions & 1 deletion src/jumar/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,17 @@ def _system_timezone() -> str:
# so that a typo in jumar.toml does not silently fail to apply the override.
_VALID_HARNESS_STAGES: frozenset[str] = frozenset({"decompose", "execute", "judge"})

# Values accepted for `reasoning_effort`. Sent verbatim to the chat-completions
# endpoint, which passes it to the model's chat template; a model that does not
# understand the key ignores it. Validated rather than free-form so a typo is a
# startup error instead of a silently ignored setting that looks applied.
# "none" is included because several thinking models spell "off" that way.
_VALID_REASONING_EFFORTS: frozenset[str] = frozenset({"none", "low", "medium", "high", "xhigh"})

# Scalar keys valid at [harness], inside a stage table, and inside a profile.
_HARNESS_SCALAR_KEYS: frozenset[str] = frozenset({"agent", "model", "base_url", "api_key_env"})
_HARNESS_SCALAR_KEYS: frozenset[str] = frozenset(
{"agent", "model", "base_url", "api_key_env", "reasoning_effort"}
)

# Sub-table under [harness] holding named alternative harnesses:
# [harness.profiles.<name>] with the same shape as [harness] itself. One is
Expand Down Expand Up @@ -208,6 +217,11 @@ class HarnessConfig:
none, e.g. a local LM Studio server). Every subprocess harness ignores
both.

``reasoning_effort`` is forwarded to an in-process harness's chat-
completions request when set. It is per-stage for the same reason the
model is: decompose and judge are single bounded calls, while execute
runs a tool loop where the cost of deliberation is paid on every turn.

``None`` for a per-stage field means "inherit the top-level value".

A ``HarnessConfig`` is always fully resolved by the time it reaches this
Expand All @@ -220,19 +234,23 @@ class HarnessConfig:
model: str = "sonnet"
base_url: str | None = None
api_key_env: str | None = None
reasoning_effort: str | None = None
# Per-stage overrides — None inherits the top-level value.
decompose_agent: str | None = None
decompose_model: str | None = None
decompose_base_url: str | None = None
decompose_api_key_env: str | None = None
decompose_reasoning_effort: str | None = None
execute_agent: str | None = None
execute_model: str | None = None
execute_base_url: str | None = None
execute_api_key_env: str | None = None
execute_reasoning_effort: str | None = None
judge_agent: str | None = None
judge_model: str | None = None
judge_base_url: str | None = None
judge_api_key_env: str | None = None
judge_reasoning_effort: str | None = None

def for_stage(self, stage: str) -> HarnessConfig:
"""Return the resolved ``HarnessConfig`` for *stage*.
Expand All @@ -249,11 +267,13 @@ def for_stage(self, stage: str) -> HarnessConfig:
m: str | None = getattr(self, f"{stage}_model")
b: str | None = getattr(self, f"{stage}_base_url")
k: str | None = getattr(self, f"{stage}_api_key_env")
r: str | None = getattr(self, f"{stage}_reasoning_effort")
return HarnessConfig(
agent=a if a is not None else self.agent,
model=m if m is not None else self.model,
base_url=b if b is not None else self.base_url,
api_key_env=k if k is not None else self.api_key_env,
reasoning_effort=r if r is not None else self.reasoning_effort,
)


Expand Down Expand Up @@ -508,6 +528,25 @@ def _first(*values: str | None) -> str | None:
)
profile_raw = cast(dict[str, Any], profiles_raw[harness_profile])

def _validate_reasoning_efforts(resolved: HarnessConfig, label: str) -> None:
"""Reject an unrecognised reasoning_effort at load, not at request time.

A bad value would otherwise be forwarded verbatim and silently ignored
by the endpoint, leaving a run that looks configured and is not. Every
profile is checked, not just the selected one, so a typo in an unused
profile still fails the next load rather than the next scheduled run
that selects it.
"""
stage_attrs = (f"{s}_reasoning_effort" for s in sorted(_VALID_HARNESS_STAGES))
for attr in ("reasoning_effort", *stage_attrs):
value = getattr(resolved, attr)
if value is not None and value not in _VALID_REASONING_EFFORTS:
where = label if attr == "reasoning_effort" else f"{label}.{attr.split('_')[0]}"
raise ConfigError(
f"Invalid reasoning_effort {value!r} under [{where}]. "
f"Valid values: {sorted(_VALID_REASONING_EFFORTS)}."
)

# Resolution order, highest first:
# [harness.profiles.<name>.<stage>] → [harness.profiles.<name>]
# → [harness.<stage>] → [harness]
Expand Down Expand Up @@ -542,16 +581,23 @@ def _build(profile_table: dict[str, Any], profile_name: str | None) -> HarnessCo
api_key_env=_first(
_scalar(profile_table, "api_key_env"), _scalar(harness_raw, "api_key_env")
),
reasoning_effort=_first(
_scalar(profile_table, "reasoning_effort"),
_scalar(harness_raw, "reasoning_effort"),
),
**stage_kwargs,
)

harness = _build(profile_raw, harness_profile)
_validate_reasoning_efforts(harness, "harness")
# Every profile is resolved, not just the selected one, so an item can
# name one with @harness= after load without re-reading the file.
harness_profiles: dict[str, HarnessConfig] = {
name: _build(cast(dict[str, Any], profiles_raw[name]), name)
for name in sorted(profiles_raw)
}
for _name, _resolved in harness_profiles.items():
_validate_reasoning_efforts(_resolved, f"harness.{_HARNESS_PROFILES_KEY}.{_name}")

commands_raw: dict[str, Any] = raw.get("commands", {})
commands = CommandPolicy(
Expand Down
1 change: 1 addition & 0 deletions src/jumar/decompose.py
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,7 @@ def decompose(
invoked_as=resolved.agent,
base_url=resolved.base_url,
api_key_env=resolved.api_key_env,
reasoning_effort=resolved.reasoning_effort,
commands_allow=config.commands.allow,
commands_deny=config.commands.deny,
)
Expand Down
1 change: 1 addition & 0 deletions src/jumar/execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ def execute(
invoked_as=resolved.agent,
base_url=resolved.base_url,
api_key_env=resolved.api_key_env,
reasoning_effort=resolved.reasoning_effort,
commands_allow=config.commands.allow,
commands_deny=config.commands.deny,
)
Expand Down
1 change: 1 addition & 0 deletions src/jumar/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ class HarnessInfo:
invoked_as: str
base_url: str | None = None
api_key_env: str | None = None
reasoning_effort: str | None = None
commands_allow: tuple[str, ...] = ()
commands_deny: tuple[str, ...] = ()

Expand Down
4 changes: 4 additions & 0 deletions src/jumar/openai_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,10 @@ def _rate() -> dict[str, Any]:
)

payload: dict[str, Any] = {"model": harness.model, "messages": messages}
# Sent only when configured, so an endpoint that rejects unknown keys
# is unaffected by the default.
if harness.reasoning_effort:
payload["reasoning_effort"] = harness.reasoning_effort
if allow_tools:
payload["tools"] = list(_TOOLS)

Expand Down
44 changes: 44 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -992,3 +992,47 @@ def test_custom_deny_list() -> None:
cfg = Config(commands=CommandPolicy(allow=("python3", "make", "git"), deny=("make",)))
assert is_allowed(["make", "check"], cfg) is False
assert is_allowed(["python3", "test.py"], cfg) is True


def test_reasoning_effort_resolves_per_stage(tmp_path: Path) -> None:
"""A stage override wins; other stages inherit the top-level value."""
cfg_path = tmp_path / "jumar.toml"
cfg_path.write_text(
"[jumar.harness]\n"
'agent = "openai"\n'
'model = "m"\n'
'reasoning_effort = "low"\n'
"[jumar.harness.execute]\n"
'reasoning_effort = "medium"\n'
)
config = load_config(tmp_path)
assert config.harness.for_stage("decompose").reasoning_effort == "low"
assert config.harness.for_stage("judge").reasoning_effort == "low"
assert config.harness.for_stage("execute").reasoning_effort == "medium"


def test_reasoning_effort_defaults_to_none(tmp_path: Path) -> None:
cfg_path = tmp_path / "jumar.toml"
cfg_path.write_text('[jumar.harness]\nagent = "openai"\nmodel = "m"\n')
config = load_config(tmp_path)
assert config.harness.for_stage("execute").reasoning_effort is None


def test_invalid_reasoning_effort_is_a_startup_error(tmp_path: Path) -> None:
cfg_path = tmp_path / "jumar.toml"
cfg_path.write_text('[jumar.harness]\nagent = "openai"\nreasoning_effort = "lwo"\n')
with pytest.raises(ConfigError, match="Invalid reasoning_effort"):
load_config(tmp_path)


def test_invalid_reasoning_effort_in_an_unselected_profile_still_fails(tmp_path: Path) -> None:
"""Every profile is validated, not only the one selected for this run."""
cfg_path = tmp_path / "jumar.toml"
cfg_path.write_text(
"[jumar.harness]\n"
'agent = "openai"\n'
"[jumar.harness.profiles.heavy]\n"
'reasoning_effort = "enormous"\n'
)
with pytest.raises(ConfigError, match="Invalid reasoning_effort"):
load_config(tmp_path)
56 changes: 56 additions & 0 deletions tests/test_openai_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import time
import urllib.error
from collections.abc import Callable, Iterator
from dataclasses import replace
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from typing import Any
Expand Down Expand Up @@ -907,3 +908,58 @@ def test_malformed_usage_values_are_ignored(tmp_path: Path, chat_server: Any) ->
assert result.exit_status == 0
assert result.prompt_tokens == 0
assert result.completion_tokens == 12


# ---------------------------------------------------------------------------
# reasoning_effort
# ---------------------------------------------------------------------------


def test_reasoning_effort_is_sent_when_configured(tmp_path: Path, chat_server: Any) -> None:
base_url, handler = chat_server([_message("All done.")])
harness = replace(_harness(base_url), reasoning_effort="low")
result = run_openai_agent(
"do the thing",
cwd=tmp_path,
capabilities=_ALL_CAPS,
timeout_s=30,
harness=harness,
)
assert result.exit_status == 0
assert handler.received[0]["reasoning_effort"] == "low"


def test_reasoning_effort_is_omitted_when_unset(tmp_path: Path, chat_server: Any) -> None:
"""An endpoint that rejects unknown keys must not see the key by default."""
base_url, handler = chat_server([_message("All done.")])
result = run_openai_agent(
"do the thing",
cwd=tmp_path,
capabilities=_ALL_CAPS,
timeout_s=30,
harness=_harness(base_url),
)
assert result.exit_status == 0
assert "reasoning_effort" not in handler.received[0]


def test_reasoning_effort_is_sent_on_every_turn_of_the_loop(
tmp_path: Path, chat_server: Any
) -> None:
(tmp_path / "foo.txt").write_text("hello world")
base_url, handler = chat_server(
[
_message("", [_tool_call("1", "read_file", {"path": "foo.txt"})]),
_message("done."),
]
)
result = run_openai_agent(
"read foo.txt",
cwd=tmp_path,
capabilities=_ALL_CAPS,
timeout_s=30,
harness=replace(_harness(base_url), reasoning_effort="medium"),
)
assert result.exit_status == 0
assert len(handler.received) == 2
assert all(r["reasoning_effort"] == "medium" for r in handler.received)
Loading