Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
570da5784e |
No files matched your search
@@ -132,21 +132,6 @@ a local model can be held to less without affecting the frontier roles.
|
|||||||
executor. Frontier planning wants the large default. Leave this high, and
|
executor. Frontier planning wants the large default. Leave this high, and
|
||||||
set a local model's smaller ceiling as its `max_output_tokens` in LiteLLM.
|
set a local model's smaller ceiling as its `max_output_tokens` in LiteLLM.
|
||||||
|
|
||||||
### `llm.request_timeout_s` — default 600
|
|
||||||
|
|
||||||
How long one model request may take before it is abandoned. It applies to
|
|
||||||
every request, on both routes.
|
|
||||||
|
|
||||||
- **Too low:** a slow model's long answer is cut off, and the executor
|
|
||||||
escalates work that was going well. At 120 s, the old fixed value, a dense
|
|
||||||
27B model on the RTX 4090 timed out writing one module in one turn.
|
|
||||||
- **Too high:** a hung connection holds the milestone for that long before
|
|
||||||
anything notices.
|
|
||||||
- **Deciding:** this is a safety net, not a budget. `max_output_tokens` and
|
|
||||||
`max_turns` bound the work; set this above the slowest single answer you
|
|
||||||
expect, which is the model's output ceiling divided by its generation
|
|
||||||
speed.
|
|
||||||
|
|
||||||
### `tuning.executor.max_turns` — default 40
|
### `tuning.executor.max_turns` — default 40
|
||||||
|
|
||||||
How many model calls one milestone may make before the executor stops with
|
How many model calls one milestone may make before the executor stops with
|
||||||
|
|||||||
@@ -18,9 +18,6 @@ llm: # A5: LiteLLM is the only LLM destination
|
|||||||
# Ceiling on each response. The model's own max_output_tokens in LiteLLM
|
# Ceiling on each response. The model's own max_output_tokens in LiteLLM
|
||||||
# caps it further, which is where to hold a local model to less.
|
# caps it further, which is where to hold a local model to less.
|
||||||
max_output_tokens: 16384
|
max_output_tokens: 16384
|
||||||
# How long one request may take before it is abandoned. A safety net for a
|
|
||||||
# hung connection; slow local models can take minutes over a long answer.
|
|
||||||
request_timeout_s: 600
|
|
||||||
|
|
||||||
roles: # A11: role -> LiteLLM model alias
|
roles: # A11: role -> LiteLLM model alias
|
||||||
executor: local-fast # the local tier; the benchmark picks the model
|
executor: local-fast # the local tier; the benchmark picks the model
|
||||||
|
|||||||
@@ -144,10 +144,7 @@ def make_llm_clients(
|
|||||||
|
|
||||||
normalized = budgeted(
|
normalized = budgeted(
|
||||||
LiteLLMClient(
|
LiteLLMClient(
|
||||||
config.llm_base_url,
|
config.llm_base_url, config.llm_token, max_tokens=config.max_output_tokens
|
||||||
config.llm_token,
|
|
||||||
max_tokens=config.max_output_tokens,
|
|
||||||
timeout_s=config.request_timeout_s,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# Delegations prefer the vendor-native route; fidelity is cheap
|
# Delegations prefer the vendor-native route; fidelity is cheap
|
||||||
@@ -155,10 +152,7 @@ def make_llm_clients(
|
|||||||
# select_client, not by where the caller happens to sit.
|
# select_client, not by where the caller happens to sit.
|
||||||
passthrough = budgeted(
|
passthrough = budgeted(
|
||||||
AnthropicPassthroughClient(
|
AnthropicPassthroughClient(
|
||||||
config.llm_base_url,
|
config.llm_base_url, config.llm_token, max_tokens=config.max_output_tokens
|
||||||
config.llm_token,
|
|
||||||
max_tokens=config.max_output_tokens,
|
|
||||||
timeout_s=config.request_timeout_s,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return normalized, passthrough
|
return normalized, passthrough
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from typing import Any
|
|||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from nisse_dev.llm import DEFAULT_MAX_OUTPUT_TOKENS, DEFAULT_REQUEST_TIMEOUT_S, RoleMap
|
from nisse_dev.llm import DEFAULT_MAX_OUTPUT_TOKENS, RoleMap
|
||||||
from nisse_dev.matrix import MatrixConfig
|
from nisse_dev.matrix import MatrixConfig
|
||||||
|
|
||||||
DEFAULT_CONFIG_FILE = "nisse-dev.yaml"
|
DEFAULT_CONFIG_FILE = "nisse-dev.yaml"
|
||||||
@@ -48,7 +48,7 @@ REQUIRED_KEYS = (
|
|||||||
"gitea.repo",
|
"gitea.repo",
|
||||||
"gitea.operators",
|
"gitea.operators",
|
||||||
)
|
)
|
||||||
OPTIONAL_KEYS = ("llm.max_output_tokens", "llm.request_timeout_s", "gitea.base_branch")
|
OPTIONAL_KEYS = ("llm.max_output_tokens", "gitea.base_branch")
|
||||||
|
|
||||||
_REFERENCE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
_REFERENCE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
||||||
|
|
||||||
@@ -221,7 +221,6 @@ class AgentConfig:
|
|||||||
gitea_operators: frozenset[str]
|
gitea_operators: frozenset[str]
|
||||||
base_branch: str = "main"
|
base_branch: str = "main"
|
||||||
max_output_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS
|
max_output_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS
|
||||||
request_timeout_s: float = DEFAULT_REQUEST_TIMEOUT_S
|
|
||||||
tuning: Tuning = field(default_factory=Tuning)
|
tuning: Tuning = field(default_factory=Tuning)
|
||||||
config_file: Path | None = None
|
config_file: Path | None = None
|
||||||
|
|
||||||
@@ -292,14 +291,6 @@ class AgentConfig:
|
|||||||
raise ValueError
|
raise ValueError
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
errors.append("llm.max_output_tokens: must be a positive integer")
|
errors.append("llm.max_output_tokens: must be a positive integer")
|
||||||
|
|
||||||
request_timeout = values.get("llm.request_timeout_s", DEFAULT_REQUEST_TIMEOUT_S)
|
|
||||||
try:
|
|
||||||
request_timeout = float(request_timeout)
|
|
||||||
if request_timeout <= 0:
|
|
||||||
raise ValueError
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
errors.append("llm.request_timeout_s: must be a positive number of seconds")
|
|
||||||
if errors:
|
if errors:
|
||||||
raise ConfigError("cannot start: " + "; ".join(errors))
|
raise ConfigError("cannot start: " + "; ".join(errors))
|
||||||
|
|
||||||
@@ -328,7 +319,6 @@ class AgentConfig:
|
|||||||
gitea_operators=_people(values["gitea.operators"]),
|
gitea_operators=_people(values["gitea.operators"]),
|
||||||
base_branch=str(values.get("gitea.base_branch") or "main"),
|
base_branch=str(values.get("gitea.base_branch") or "main"),
|
||||||
max_output_tokens=max_output,
|
max_output_tokens=max_output,
|
||||||
request_timeout_s=request_timeout,
|
|
||||||
tuning=tuning,
|
tuning=tuning,
|
||||||
config_file=config_file,
|
config_file=config_file,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -46,12 +46,6 @@ RATE_LIMIT_RETRY_S = 20.0
|
|||||||
# was observed at 4,064 against a 4,096 limit.
|
# was observed at 4,064 against a 4,096 limit.
|
||||||
DEFAULT_MAX_OUTPUT_TOKENS = 16384
|
DEFAULT_MAX_OUTPUT_TOKENS = 16384
|
||||||
|
|
||||||
# How long one request may take before it is abandoned (llm.request_timeout_s).
|
|
||||||
# A safety net for a hung connection, not a budget: a dense local model
|
|
||||||
# writing a whole module in one turn was measured taking over 120 s, and
|
|
||||||
# timing it out failed work that was going well.
|
|
||||||
DEFAULT_REQUEST_TIMEOUT_S = 600.0
|
|
||||||
|
|
||||||
|
|
||||||
class UpstreamUnavailable(LLMError):
|
class UpstreamUnavailable(LLMError):
|
||||||
"""The proxy or provider could not be reached, or refused for reasons
|
"""The proxy or provider could not be reached, or refused for reasons
|
||||||
@@ -130,7 +124,7 @@ class LiteLLMClient:
|
|||||||
base_url: str,
|
base_url: str,
|
||||||
token: str,
|
token: str,
|
||||||
*,
|
*,
|
||||||
timeout_s: float = DEFAULT_REQUEST_TIMEOUT_S,
|
timeout_s: float = 120.0,
|
||||||
transport: httpx.BaseTransport | None = None,
|
transport: httpx.BaseTransport | None = None,
|
||||||
sleep: Any = time.sleep,
|
sleep: Any = time.sleep,
|
||||||
max_tokens: int | None = None,
|
max_tokens: int | None = None,
|
||||||
@@ -256,7 +250,7 @@ class AnthropicPassthroughClient:
|
|||||||
base_url: str,
|
base_url: str,
|
||||||
token: str,
|
token: str,
|
||||||
*,
|
*,
|
||||||
timeout_s: float = DEFAULT_REQUEST_TIMEOUT_S,
|
timeout_s: float = 300.0,
|
||||||
max_tokens: int | None = None,
|
max_tokens: int | None = None,
|
||||||
transport: httpx.BaseTransport | None = None,
|
transport: httpx.BaseTransport | None = None,
|
||||||
sleep: Any = time.sleep,
|
sleep: Any = time.sleep,
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class OpenAIChatRenderer:
|
|||||||
kept = effective_events(events)
|
kept = effective_events(events)
|
||||||
answered = {e.event.call_id for e in kept if isinstance(e.event, ToolResult)}
|
answered = {e.event.call_id for e in kept if isinstance(e.event, ToolResult)}
|
||||||
for stored in kept:
|
for stored in kept:
|
||||||
_append_merged(messages, self._message_for(stored.event))
|
messages.append(self._message_for(stored.event))
|
||||||
# Providers reject a tool call with no result following it. One
|
# Providers reject a tool call with no result following it. One
|
||||||
# can legitimately be missing: a crash between the call and its
|
# can legitimately be missing: a crash between the call and its
|
||||||
# result (A3), or a turn-ending gate tool. Render a placeholder so
|
# result (A3), or a turn-ending gate tool. Render a placeholder so
|
||||||
@@ -128,28 +128,3 @@ class OpenAIChatRenderer:
|
|||||||
}
|
}
|
||||||
case _:
|
case _:
|
||||||
raise ValueError(f"unrenderable event: {event!r}")
|
raise ValueError(f"unrenderable event: {event!r}")
|
||||||
|
|
||||||
|
|
||||||
def _append_merged(messages: list[dict[str, Any]], message: dict[str, Any]) -> None:
|
|
||||||
"""Append, folding into the previous message when both are the same user
|
|
||||||
or assistant role.
|
|
||||||
|
|
||||||
One model turn is often several events (some text, then one or more tool
|
|
||||||
calls), and the OpenAI format carries a turn as one assistant message
|
|
||||||
with a tool_calls list. Strict chat templates enforce that: Devstral's
|
|
||||||
(Mistral) rejected a turn rendered as a text message then a tool-call
|
|
||||||
message with a 500, failing runs whenever the model spoke before acting."""
|
|
||||||
previous = messages[-1] if messages else None
|
|
||||||
role = message["role"]
|
|
||||||
if (
|
|
||||||
previous is None
|
|
||||||
or previous["role"] != role
|
|
||||||
or role not in ("user", "assistant")
|
|
||||||
):
|
|
||||||
messages.append(message)
|
|
||||||
return
|
|
||||||
texts = [t for t in (previous.get("content"), message.get("content")) if t]
|
|
||||||
previous["content"] = "\n\n".join(texts) if texts else None
|
|
||||||
calls = previous.get("tool_calls", []) + message.get("tool_calls", [])
|
|
||||||
if calls:
|
|
||||||
previous["tool_calls"] = calls
|
|
||||||
@@ -5,7 +5,6 @@ from typing import Any
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nisse_dev.config import AgentConfig, ConfigError
|
from nisse_dev.config import AgentConfig, ConfigError
|
||||||
from nisse_dev.llm import DEFAULT_REQUEST_TIMEOUT_S
|
|
||||||
|
|
||||||
# A complete config as the YAML file would give it, with secrets by reference.
|
# A complete config as the YAML file would give it, with secrets by reference.
|
||||||
COMPLETE: dict[str, Any] = {
|
COMPLETE: dict[str, Any] = {
|
||||||
@@ -123,25 +122,6 @@ def test_max_output_tokens_is_configurable_and_checked() -> None:
|
|||||||
build(llm__max_output_tokens=0)
|
build(llm__max_output_tokens=0)
|
||||||
|
|
||||||
|
|
||||||
def test_request_timeout_is_configurable_and_checked() -> None:
|
|
||||||
assert build().request_timeout_s == DEFAULT_REQUEST_TIMEOUT_S
|
|
||||||
assert build(llm__request_timeout_s=900).request_timeout_s == 900.0
|
|
||||||
for bad in (0, -5, "soon"):
|
|
||||||
with pytest.raises(ConfigError, match="llm.request_timeout_s"):
|
|
||||||
build(llm__request_timeout_s=bad)
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_request_timeout_reaches_both_clients() -> None:
|
|
||||||
from nisse_dev.agent import make_llm_clients
|
|
||||||
from nisse_dev.catalog import ModelCatalog
|
|
||||||
|
|
||||||
normalized, passthrough = make_llm_clients(
|
|
||||||
build(llm__request_timeout_s=42), ModelCatalog({})
|
|
||||||
)
|
|
||||||
for client in (normalized, passthrough):
|
|
||||||
assert client._inner._http.timeout.read == 42.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_redacted_summary_carries_no_secrets() -> None:
|
def test_redacted_summary_carries_no_secrets() -> None:
|
||||||
summary = build().redacted()
|
summary = build().redacted()
|
||||||
flat = " ".join(summary.values())
|
flat = " ".join(summary.values())
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import json
|
import json
|
||||||
from itertools import pairwise
|
|
||||||
|
|
||||||
from nisse_dev.canonical import (
|
from nisse_dev.canonical import (
|
||||||
AssistantText,
|
AssistantText,
|
||||||
@@ -69,8 +68,7 @@ def test_compaction_replaces_its_range_only() -> None:
|
|||||||
assert [e.id for e in remaining] == [3, 4]
|
assert [e.id for e in remaining] == [3, 4]
|
||||||
body = OpenAIChatRenderer().render(events, tools=None, model="q")
|
body = OpenAIChatRenderer().render(events, tools=None, model="q")
|
||||||
contents = [m["content"] for m in body["messages"]]
|
contents = [m["content"] for m in body["messages"]]
|
||||||
# Both render as user messages, so they share one: roles must alternate.
|
assert contents == ["kept", "[summary of earlier conversation]\nthe old stuff"]
|
||||||
assert contents == ["kept\n\n[summary of earlier conversation]\nthe old stuff"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_tool_call_without_a_result_gets_a_placeholder() -> None:
|
def test_tool_call_without_a_result_gets_a_placeholder() -> None:
|
||||||
@@ -105,47 +103,3 @@ def test_gate_event_renders_as_context() -> None:
|
|||||||
body = OpenAIChatRenderer().render(events, tools=None, model="q")
|
body = OpenAIChatRenderer().render(events, tools=None, model="q")
|
||||||
assert body["messages"][0]["role"] == "user"
|
assert body["messages"][0]["role"] == "user"
|
||||||
assert "arch" in body["messages"][0]["content"]
|
assert "arch" in body["messages"][0]["content"]
|
||||||
|
|
||||||
|
|
||||||
def _roles(body: dict) -> list[str]:
|
|
||||||
return [m["role"] for m in body["messages"]]
|
|
||||||
|
|
||||||
|
|
||||||
def test_one_turn_is_one_assistant_message() -> None:
|
|
||||||
# Text and two parallel tool calls from one model turn: the OpenAI format
|
|
||||||
# carries them as a single assistant message with a tool_calls list.
|
|
||||||
events = stored(
|
|
||||||
[
|
|
||||||
UserMessage(text="go", source="m:1"),
|
|
||||||
AssistantText(text="Looking around first.", model="q"),
|
|
||||||
ToolCall(call_id="a", tool="list_dir", arguments={"path": "."}),
|
|
||||||
ToolCall(call_id="b", tool="read_file", arguments={"path": "README.md"}),
|
|
||||||
ToolResult(call_id="a", result="README.md", success=True),
|
|
||||||
ToolResult(call_id="b", result="hello", success=True),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
body = OpenAIChatRenderer().render(events, tools=None, model="q")
|
|
||||||
assert _roles(body) == ["user", "assistant", "tool", "tool"]
|
|
||||||
turn = body["messages"][1]
|
|
||||||
assert turn["content"] == "Looking around first."
|
|
||||||
assert [c["id"] for c in turn["tool_calls"]] == ["a", "b"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_roles_never_repeat_for_strict_templates() -> None:
|
|
||||||
"""Mistral's chat template raises on two user or two assistant messages
|
|
||||||
in a row; every mix of events must render without one."""
|
|
||||||
events = stored(
|
|
||||||
[
|
|
||||||
UserMessage(text="one", source="m:1"),
|
|
||||||
UserMessage(text="two", source="m:2"),
|
|
||||||
AssistantText(text="thinking", model="q"),
|
|
||||||
ToolCall(call_id="a", tool="list_dir", arguments={}),
|
|
||||||
ToolResult(call_id="a", result="x", success=True),
|
|
||||||
AssistantText(text="more", model="q"),
|
|
||||||
AssistantText(text="and more", model="q"),
|
|
||||||
ToolCall(call_id="b", tool="finish", arguments={}),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
roles = _roles(OpenAIChatRenderer().render(events, tools=None, model="q"))
|
|
||||||
for first, second in pairwise(roles):
|
|
||||||
assert not (first == second and first in ("user", "assistant")), roles
|
|
||||||
Reference in new issue
Block a user