Skip to content

Commit a433bb4

Browse files
committed
Add multi-LLM /model switching, settings precedence, round-time persistence
- config: named 'models' profiles in config.json (load_models_config) - session: switch_model() applies profile settings over llm config, preserves conversation history, applies timeout to the HTTP pool - TUI: /model lists profiles with numbered selection and switches by name; /restore latest alias; persisted round timestamps restored from session metadata - docs: README models section and /model command
1 parent 7c02cff commit a433bb4

11 files changed

Lines changed: 688 additions & 15 deletions

File tree

README.md

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,16 +81,32 @@ LLM settings live in a JSON config file — no environment variables required:
8181
```json
8282
{
8383
"llm": {
84-
"base_url": "https://api.deepseek.com/v1",
84+
"base_url": "https://api.openai.com/v1",
8585
"api_key": "sk-...",
86-
"model": "deepseek-chat",
87-
"reasoning_effort": "medium",
86+
"model": "gpt-5-mini",
87+
"reasoning_effort": null,
8888
"stream": true
8989
},
90+
"models": {
91+
"_comment": "Named LLM profiles for /model switching. Each entry is a partial set of LLM settings; unset keys inherit the main llm.",
92+
"deepseek": {
93+
"base_url": "https://api.deepseek.com/v1",
94+
"model": "deepseek-chat",
95+
"reasoning_effort": "medium"
96+
},
97+
"qwen": {
98+
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
99+
"model": "qwen3.5-coder"
100+
},
101+
"kimi": {
102+
"base_url": "https://api.moonshot.cn/v1",
103+
"model": "kimi-k2.7"
104+
}
105+
},
90106
"subagent_llm": {
91107
"base_url": null,
92108
"api_key": null,
93-
"model": "deepseek-chat",
109+
"model": null,
94110
"temperature": null,
95111
"max_tokens": null,
96112
"timeout": null,
@@ -120,6 +136,7 @@ LLM settings live in a JSON config file — no environment variables required:
120136
- `reasoning_effort` is passed to the API as-is (omitted when unset) — whatever your provider accepts ("low"/"medium"/"high").
121137
- Other optional keys: `backend`, `temperature`, `max_tokens`, `timeout`, `stream` (`true` by default; `run --no-stream` overrides on the command line).
122138
- `subagent_llm` configures the LLM for Agent-tool requests: every key is optional and unset keys inherit the main `llm`, so a cheaper/smaller model (or a different provider) can serve delegated work.
139+
- **Named model profiles** (`models` section) enable runtime switching via `/model`: each profile is a partial LLM settings dict; unset keys inherit the main `llm`. Use `/model` in the TUI to switch between providers/models without restarting.
123140
- `paths.context_path` / `paths.skill_path` override context/skill discovery — defaults are `<project>/contexts` or `~/.emacs.d/contexts` (skills: `<project>/skills` or `~/.emacs.d/skills`).
124141
- `mcp.servers` configures MCP servers (requires the `[mcp]` extra). Each server is `{transport, command, args, env, url, headers, parallel, timeout, enabled}``stdio` needs `command`/`args` (optionally `env` naming environment variables to pass through, e.g. `["GITHUB_TOKEN"]`); `streamable-http`/`sse` need `url` (optionally `headers`). Its tools appear as `mcp__<server>__<tool>`.
125142
- Precedence: code defaults < config file < `OPENAI_*` env vars (env still wins if set, but nothing is required). Sub-agent settings honor `OPENAI_SUBAGENT_*` (`_BASE_URL`, `_API_KEY`, `_MODEL`, `_BACKEND`).
@@ -145,6 +162,7 @@ python-agent-harness run [project-dir] # interactive TUI agent
145162
| `/sessions` | list saved sessions |
146163
| `/restore [path\|title\|--latest]` | restore a session (title substring match) |
147164
| `/clear` | start a fresh conversation |
165+
| `/model [name]` | switch LLM model profile (no arg: list available; with arg: switch to that profile) |
148166
| `/exit` | quit |
149167

150168
Custom commands from `prompts/commands/*.md` are registered as slash commands too (TUI-only — no CLI subcommand is registered for them). Tool availability differs per command: `/init` and `/review` may use all tools except `PlanExit` (hidden for the run, including for spawned sub-agents); custom commands may use everything; `compact`/`summary` run with no tools (a one-shot `chat_sync` call, like session-title generation).

python_agent_harness/agent_session.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,9 @@ def __init__(
9090
skill_path: str | None = None,
9191
mcp: MCPConfig | None = None,
9292
mcp_manager: MCPManager | None = None,
93+
model_profiles: dict[str, dict] | None = None,
94+
llm_settings: dict | None = None,
95+
config_path: str | None = None,
9396
) -> None:
9497
self.project_dir = project_dir
9598
self.client = client
@@ -170,6 +173,12 @@ def __init__(
170173
self._save_error: str | None = None
171174
self.last_messages: list = []
172175
self.cancel_event = threading.Event()
176+
# Named LLM profiles for runtime switching via /model command
177+
self.model_profiles: dict[str, dict] = model_profiles or {}
178+
# Resolved main llm settings (base for /model switching): a
179+
# model profile's unset keys inherit these values, so switching
180+
# between profiles never drifts settings from earlier switches.
181+
self.llm_settings: dict = dict(llm_settings) if llm_settings else {}
173182
# Monotonic cancel identity: cancel() bumps this counter, so a
174183
# worker from a cancelled run can tell it was cancelled even
175184
# after the next run clears the shared event.
@@ -608,6 +617,76 @@ def cancel(self) -> None:
608617
with contextlib.suppress(Exception): # best effort
609618
c.abort()
610619

620+
# ------------------------------------------------------------------
621+
# model switching
622+
# ------------------------------------------------------------------
623+
def switch_model(self, name: str) -> tuple[bool, str]:
624+
"""Switch to a named LLM profile.
625+
626+
Model-specific settings take precedence over the main ``llm``
627+
config; keys the profile leaves unset inherit the main ``llm``
628+
settings as resolved at session start (so switching between
629+
profiles never drifts values from earlier switches). The
630+
client and session are updated in place. Returns
631+
(success, message).
632+
"""
633+
if not self.model_profiles or name not in self.model_profiles:
634+
available = (
635+
", ".join(sorted(self.model_profiles.keys()))
636+
if self.model_profiles
637+
else "(none configured)"
638+
)
639+
return False, f"unknown model: {name} (available: {available})"
640+
profile = self.model_profiles[name]
641+
# Effective settings: main llm config (resolved at session
642+
# start) overlaid with the profile's own settings. Profile
643+
# keys that are set (not None) win; unset keys inherit the llm
644+
# config, which itself falls back to the current session values
645+
# for callers that don't pass llm_settings.
646+
merged = dict(self.llm_settings)
647+
current = {
648+
"base_url": self.client.base_url,
649+
"api_key": self.client.api_key,
650+
"model": self.model,
651+
"backend": self.backend,
652+
"temperature": self.temperature,
653+
"max_tokens": self.max_tokens,
654+
"timeout": self.client.timeout,
655+
"reasoning_effort": self.reasoning_effort,
656+
"stream": self.stream,
657+
}
658+
for key, val in current.items():
659+
merged.setdefault(key, val)
660+
for key in (
661+
"base_url",
662+
"api_key",
663+
"model",
664+
"backend",
665+
"temperature",
666+
"max_tokens",
667+
"timeout",
668+
"reasoning_effort",
669+
"stream",
670+
):
671+
if key in profile and profile[key] is not None:
672+
merged[key] = profile[key]
673+
self.client.base_url = str(merged["base_url"]).rstrip("/")
674+
self.client.api_key = merged["api_key"]
675+
self.client.model = merged["model"]
676+
self.model = merged["model"]
677+
self.store.model = merged["model"]
678+
self.backend = merged["backend"]
679+
self.store.backend = merged["backend"]
680+
self.temperature = merged["temperature"]
681+
self.max_tokens = merged["max_tokens"]
682+
if hasattr(self.client, "set_timeout"):
683+
self.client.set_timeout(merged["timeout"])
684+
else:
685+
self.client.timeout = merged["timeout"]
686+
self.reasoning_effort = merged["reasoning_effort"]
687+
self.stream = merged["stream"]
688+
return True, f"switched to {name} ({self.model})"
689+
611690
# ------------------------------------------------------------------
612691
# direct commands: compact / summary (no agent loop)
613692
# ------------------------------------------------------------------

python_agent_harness/cli.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,13 @@ def make_session(
9090
# over the config file for the whole session, sub-agents included
9191
# (same precedence as the main agent's stream).
9292
effective_stream = settings["stream"] if stream is None else stream
93+
model_profiles = config.load_models_config(config_path)
94+
# Base settings for /model switching: the main llm settings as
95+
# resolved at session start (incl. the CLI --model/--no-stream
96+
# overrides above), so a profile's unset keys inherit these
97+
# instead of values drifted by earlier switches.
98+
llm_settings = dict(settings)
99+
llm_settings["stream"] = effective_stream
93100
return AgentSession(
94101
project_dir=abs_project,
95102
client=client,
@@ -110,6 +117,9 @@ def make_session(
110117
context_path=paths.get("context_path"),
111118
skill_path=paths.get("skill_path"),
112119
mcp=mcp_config,
120+
model_profiles=model_profiles,
121+
llm_settings=llm_settings,
122+
config_path=config_path,
113123
)
114124

115125

@@ -201,6 +211,16 @@ def cmd_config(args: argparse.Namespace) -> int:
201211
f"reasoning_effort={subagent_settings['reasoning_effort']} "
202212
f"stream={subagent_settings['stream']} timeout={subagent_settings['timeout']}"
203213
)
214+
# Show model profiles for /model command
215+
model_profiles = config.load_models_config(args.path)
216+
if model_profiles:
217+
print("models:")
218+
for name, profile in sorted(model_profiles.items()):
219+
model_name = profile.get("model", "(inherited)")
220+
base_url = profile.get("base_url", "(inherited)")
221+
print(f" {name}: model={model_name}, base_url={base_url}")
222+
else:
223+
print("models: (none configured — add a 'models' section to use /model)")
204224
return 0
205225

206226

python_agent_harness/client.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,18 @@ def _reset_http(self) -> None:
286286
with contextlib.suppress(Exception): # best effort
287287
old.close()
288288

289+
def set_timeout(self, timeout: float) -> None:
290+
"""Update the request timeout and recreate the HTTP pool.
291+
292+
The pool is created once in ``__init__`` with the initial
293+
timeout, so changing the attribute alone would not affect
294+
in-flight/future requests. Recreating the pool makes the new
295+
timeout apply to subsequent requests immediately (used by
296+
/model switching).
297+
"""
298+
self.timeout = timeout
299+
self._reset_http()
300+
289301
def _refresh_api_key(
290302
self,
291303
cancel_check: Callable[[], bool] | None = None,

python_agent_harness/config.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,8 @@
184184
# Requires the optional `mcp` extra: pip install -e ".[mcp]".
185185
DEFAULT_MCP: dict = {"servers": {}}
186186

187+
DEFAULT_MODELS: dict = {}
188+
187189
# Sub-agent LLM overrides: every key defaults to None, meaning "inherit
188190
# the main LLM setting" (mirrors gptel-agent-harness-subagent-model /
189191
# -backend). Only the keys the user actually sets differ from the main
@@ -209,6 +211,17 @@
209211
"reasoning_effort": "medium",
210212
"stream": true
211213
}},
214+
"models": {{
215+
"_comment": "Named LLM profiles for /model switching. Each entry is a full set of LLM settings (base_url, api_key, model, etc.). Use /model in the TUI to switch at runtime.",
216+
"deepseek": {{
217+
"base_url": "https://api.deepseek.com/v1",
218+
"model": "deepseek-chat"
219+
}},
220+
"openai": {{
221+
"base_url": "https://api.openai.com/v1",
222+
"model": "gpt-5-mini"
223+
}}
224+
}},
212225
"subagent_llm": {{
213226
"_comment": "Optional overrides for sub-agent (Agent tool) requests, e.g. a cheaper model. Every key is optional; unset keys inherit the main llm settings above.",
214227
"base_url": null,
@@ -403,5 +416,37 @@ def load_mcp_config(path: str | os.PathLike | None = None) -> MCPConfig:
403416
return MCPConfig.from_dict(section.get("servers"))
404417

405418

419+
def load_models_config(path: str | os.PathLike | None = None) -> dict[str, dict]:
420+
"""Load named LLM profiles from the config file's ``models`` object.
421+
422+
Returns a dict mapping profile names to their LLM settings dicts.
423+
Each profile is a partial set of DEFAULT_LLM keys (base_url, api_key,
424+
model, etc.); unset keys inherit the main ``llm`` settings when the
425+
profile is applied. An empty dict when the file has no ``models``
426+
section or it is empty.
427+
"""
428+
import json
429+
430+
cfg_path = _config_path(path)
431+
if not cfg_path.exists():
432+
return {}
433+
try:
434+
with open(cfg_path, "rb") as f:
435+
data = json.load(f)
436+
except Exception as e: # noqa: BLE001
437+
raise ValueError(f"cannot read config file {cfg_path}: {e}") from e
438+
section = data.get("models") or {}
439+
if not isinstance(section, dict):
440+
raise ValueError(f"config file {cfg_path}: models must be an object")
441+
profiles: dict[str, dict] = {}
442+
for name, val in section.items():
443+
if name.startswith("_"):
444+
continue
445+
if not isinstance(val, dict):
446+
raise ValueError(f"config file {cfg_path}: models.{name} must be an object")
447+
profiles[name] = val
448+
return profiles
449+
450+
406451
def mask_secret(value: str | None) -> str:
407452
return "****" if value else "(unset)"

python_agent_harness/session_store.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ def __init__(
111111
temperature: float | None = None,
112112
max_tokens: int | None = None,
113113
tool_names: list[str] | None = None,
114+
round_times: list[float] | None = None,
114115
) -> None:
115116
self.project_dir = project_dir
116117
self.model = model
@@ -123,6 +124,10 @@ def __init__(
123124
self.file_path: str | None = None
124125
self.title_pending = False
125126
self._first_user_msg: str | None = None
127+
# wall-clock start times of each round, persisted in the
128+
# metadata block so restored sessions keep their round
129+
# timestamps (populated by the TUI on each run)
130+
self.round_times: list[float] = list(round_times) if round_times else []
126131
# serializes save vs. apply_title: the rename must never
127132
# interleave with a save's write+replace, or the conversation
128133
# would split across two files (a titled stale file plus a
@@ -179,6 +184,9 @@ def metadata_block(self) -> str:
179184
if self.tool_names:
180185
names = " ".join(f'"{n}"' for n in self.tool_names)
181186
lines.append(f";; gptel--tool-names: ({names})")
187+
if self.round_times:
188+
stamps = " ".join(repr(float(t)) for t in self.round_times)
189+
lines.append(f";; python-agent-harness--round-times: {stamps}")
182190
lines.append(";; End:")
183191
return "\n".join(lines)
184192

0 commit comments

Comments
 (0)