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
4 changes: 3 additions & 1 deletion arktor_example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ llm:
max_retries: 5 # Max retry attempts for rate-limit errors
retry_delay: 1.0 # Initial retry delay in seconds (exponential backoff)
reasoning_effort: "" # "low" | "medium" | "high" | "xhigh/max.." (leave empty to use model default)
sub_model: # Context window must be >= memory.max_tokens
model: "" # Empty = use main model
reasoning_effort: "" # Empty = use the sub model's default

# --- Tool Execution ---
tool:
Expand Down Expand Up @@ -43,7 +46,6 @@ memory:
compression: # Only effective when strategy = "summarize"
threshold: 0.9 # Compress when token usage > threshold of max_tokens
retain_count: 3 # Keep last N message groups intact (never compressed)
summary_model: "" # Model for summaries (empty = use main model)
summary_max_tokens: null # Max output tokens for summary (null = use LLM config default)

# --- Search ---
Expand Down
58 changes: 48 additions & 10 deletions src/agent_app/tools/sub_agent/sub_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@
3. The sub-agent's outputs should generally be trusted.
4. Set background=true when your workflow can proceed without waiting \
for this result.
5. Choose model independently of agent type and background mode: use sub for \
well-specified work that does not require difficult inference, reconciling \
competing evidence, or consequential judgment. Use main otherwise, including \
when unsure; choose by the reasoning and judgment required, not the complexity \
of what it examines or the workload.

## Examples

Expand All @@ -56,7 +61,9 @@
the context cost of scanning many files. The main agent receives \
three focused reports and synthesizes the comparison, rather than \
carrying the full exploration history of all three modules in its \
own context.
own context. Each sub-agent analyzes one established implementation \
and returns directly checkable evidence rather than reconciling the \
modules, so use model="sub".
</commentary>
</example>

Expand All @@ -72,7 +79,9 @@
into a structured analysis. This keeps the main thread clean of \
the heavy exploration overhead. The main agent receives a concise \
architectural summary to reference when designing the implementation, \
rather than carrying hundreds of file reads in its own context.
rather than carrying hundreds of file reads in its own context. \
Because deriving architecture and design patterns across an entire codebase \
requires broad synthesis, this sub-agent uses model="main".
</commentary>
</example>

Expand All @@ -86,7 +95,8 @@
across many files. The sub-agent reads the codebase deeply, evaluates \
trade-offs, and produces a structured plan. This is analysis-heavy \
work that benefits from context isolation — the main agent receives \
the plan and can then proceed based on its recommendations.
the plan and can then proceed based on its recommendations. \
The architectural trade-offs require model="main".
</commentary>
</example>

Expand All @@ -101,7 +111,10 @@
modifications or command execution, the scope is well-defined, and \
you don't need the intermediate details in your context. If the task is small \
or you need the details in your own context \
for subsequent work, do it yourself instead.
for subsequent work, do it yourself instead. Although this makes the task \
suitable for delegation, designing tests still requires interpreting intended \
behavior and deciding which normal, boundary, and failure cases matter, so use \
model="main".
</commentary>
</example>

Expand All @@ -115,7 +128,9 @@
The analysis will take many steps exploring an external codebase. \
Meanwhile the agent can begin reading our own code — work that \
doesn't depend on the analysis result. When the background research \
completes, the agent combines both to plan the refactoring.
completes, the agent combines both to plan the refactoring. Because the findings \
require broad architectural synthesis and will shape later refactoring decisions, \
use model="main".
</commentary>
</example>

Expand Down Expand Up @@ -144,7 +159,9 @@
A custom "security" type has been configured for this project with \
specialized review instructions. The task falls within this agent's specialized focus area, \
so it can apply its tailored instructions and capabilities more effectively than general. \
Custom types appear in the agent_type enum — use them when the task matches their description.
Custom types appear in the agent_type enum — use them when the task matches their description. \
That specialization determines the agent type, while the subtle, high-impact \
judgment required by security review calls for model="main".
</commentary>
</example>
"""
Expand Down Expand Up @@ -210,6 +227,18 @@ def get_schema(self) -> ToolSchema:
"enum": type_enum,
"description": "Select one of the available agent types listed above.",
},
"model": {
"type": "string",
"enum": ["main", "sub"],
"description": (
"Model for this delegated task. Use 'sub' for "
"well-specified work that does not require difficult "
"inference, reconciling competing evidence, or "
"consequential judgment; use 'main' otherwise, "
"including when unsure."
),
"default": "main",
},
"background": {
"type": "boolean",
"description": (
Expand All @@ -220,7 +249,7 @@ def get_schema(self) -> ToolSchema:
"default": False,
},
},
"required": ["description", "prompt", "agent_type"],
"required": ["description", "prompt", "agent_type", "model"],
},
)

Expand All @@ -234,6 +263,7 @@ async def execute(self, **kwargs: Any) -> str:
description = kwargs.get("description", "")
prompt = kwargs.get("prompt", "")
agent_type = kwargs.get("agent_type", "")
model = kwargs.get("model")
background = kwargs.get("background", False)

if not description.strip():
Expand All @@ -242,16 +272,23 @@ async def execute(self, **kwargs: Any) -> str:
raise ToolValidationError("'prompt' is required and cannot be empty.")
if not agent_type.strip():
raise ToolValidationError("'agent_type' is required and cannot be empty.")
if not isinstance(model, str) or not model.strip():
raise ToolValidationError("'model' is required and cannot be empty.")
if model not in ("main", "sub"):
raise ToolValidationError(
f"Unknown model selection {model!r}. Available: ['main', 'sub']"
)

available_types = self._get_available_types()
if agent_type not in available_types:
raise ToolValidationError(
f"Unknown agent_type '{agent_type}'. "
f"Available: {list(available_types.keys())}"
f"Unknown agent_type {agent_type!r}. "
f"Available: {list(available_types)}"
)

tools = self._resolve_tools(agent_type)
prompt_builder = self._build_subagent_prompt_builder(agent_type)
child_llm = self._agent.llm if model == "main" else self._agent.sub_llm

parent_name = self._agent.name
self._type_seq[agent_type] = self._type_seq.get(agent_type, 0) + 1
Expand All @@ -264,7 +301,8 @@ async def execute(self, **kwargs: Any) -> str:

child = ReActAgent(
name=subagent_name,
llm=self._agent.llm,
llm=child_llm,
sub_llm=self._agent.sub_llm,
tools=tools,
context=self._agent.context.fork(f"sub.{agent_type}.{seq}"),
hooks=self._agent.hooks,
Expand Down
23 changes: 21 additions & 2 deletions src/agent_cli/commands/builtin/effort.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from __future__ import annotations

from agent_cli.commands.base import Command, CommandContext, CommandResult
from agent_cli.commands.ui import info, ok
from agent_cli.commands.ui import err, info, ok


async def handle(ctx: CommandContext, args: str) -> CommandResult:
Expand All @@ -15,6 +15,25 @@ async def handle(ctx: CommandContext, args: str) -> CommandResult:
(current or "default"),
))

parts = raw.split(maxsplit=1)
if len(parts) == 2 and parts[0] == "sub":
value = parts[1]
sub_cfg = agent.context.config.llm.sub_model
if (
agent.sub_llm is agent.llm
or sub_cfg is None
or sub_cfg.model is None
):
return CommandResult(output=err(
"No separate sub model is configured",
))
agent.sub_llm.config.reasoning_effort = value
sub_cfg.reasoning_effort = value
return CommandResult(output=ok(
"Sub-model reasoning effort set to ",
value,
))

agent.llm.config.reasoning_effort = raw
agent.context.config.llm.reasoning_effort = raw
return CommandResult(output=ok(
Expand All @@ -25,6 +44,6 @@ async def handle(ctx: CommandContext, args: str) -> CommandResult:

CMD = Command(
name="/effort",
description="Set LLM reasoning effort for this session",
description="Set LLM reasoning effort for this session, support [sub]",
handler=handle,
)
16 changes: 3 additions & 13 deletions src/agent_cli/commands/builtin/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@

from agent_cli.commands.base import Command, CommandContext, CommandResult
from agent_cli.commands.ui import err, info, ok
from agent_cli.runtime import session as sess
from agent_harness.llm import create_llm


Expand All @@ -17,24 +16,15 @@ async def handle(ctx: CommandContext, args: str) -> CommandResult:
))

config = agent.context.config
# Clone the full current LLMConfig and only swap `model` — preserves
# temperature / max_tokens / reasoning_effort etc. The framework's
# `create_llm(config, model_override=...)` drops those fields, which would
# silently reset user-configured inference parameters.
try:
new_cfg = config.llm.model_copy(update={"model": new_model})
new_llm = create_llm(new_cfg)
except Exception as e:
return CommandResult(output=err(f"Failed to switch model: {e}"))

new_llm.set_event_bus(agent.context.event_bus)
agent.llm = new_llm
agent.context.short_term_memory.model = new_model
agent.context.short_term_memory.clear_call_snapshot()

sess.update_compressor_model(agent, new_model, new_llm)

config.llm.model = new_model
new_sub_llm = agent.sub_llm if agent.sub_llm is not agent.llm else new_llm
config.llm = new_cfg
agent.replace_llms(new_llm, new_sub_llm)
return CommandResult(output=ok(
"Model switched to ",
(new_model, "bold"),
Expand Down
41 changes: 31 additions & 10 deletions src/agent_cli/commands/builtin/provider.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
"""/provider — list or switch LLM provider profiles from cli-prefs.json."""
from __future__ import annotations

from contextlib import suppress
from typing import Any

from agent_cli.commands.base import Command, CommandContext, CommandResult
from agent_cli.commands.ui import err, ok, render_provider_list
from agent_cli.runtime import session as sess
from agent_cli.runtime.prefs import read_prefs
from agent_harness.core.config import LLMConfig
from agent_harness.llm import create_llm
from agent_harness.core.config import LLMConfig, SubModelConfig
from agent_harness.llm import BaseLLM, create_llm, create_sub_llm


def _active_profile(profiles: dict[str, Any], current: LLMConfig) -> str | None:
Expand Down Expand Up @@ -49,20 +49,41 @@ async def handle(ctx: CommandContext, args: str) -> CommandResult:

keep = {
k: v for k, v in agent.context.config.llm.model_dump().items()
if k not in {"provider", "model", "base_url", "api_key"}
if k not in {"provider", "model", "base_url", "api_key", "sub_model"}
}
profile = dict(prof)
raw_sub_model = profile.pop("sub_model", None)
new_llm: BaseLLM | None = None
new_sub_llm: BaseLLM | None = None
try:
new_cfg = LLMConfig(**{**keep, **prof})
profile_sub = SubModelConfig(model=raw_sub_model)
if profile_sub.model is not None:
current_sub = agent.context.config.llm.sub_model
profile["sub_model"] = {
"model": profile_sub.model,
"reasoning_effort": (
current_sub.reasoning_effort
if current_sub is not None
else None
),
}

new_cfg = LLMConfig(**{**keep, **profile})
new_llm = create_llm(new_cfg)
new_sub_llm = create_sub_llm(new_cfg) or new_llm
except Exception as e:
created = {
id(llm): llm
for llm in (new_llm, new_sub_llm)
if llm is not None
}
for llm in created.values():
with suppress(Exception):
await llm.aclose()
return CommandResult(output=err(f"Failed to switch provider: {e}"))

new_llm.set_event_bus(agent.context.event_bus)
agent.llm = new_llm
agent.context.short_term_memory.model = new_cfg.model
agent.context.short_term_memory.clear_call_snapshot()
sess.update_compressor_model(agent, new_cfg.model, new_llm)
agent.context.config.llm = new_cfg
agent.replace_llms(new_llm, new_sub_llm)

return CommandResult(output=ok(
"Provider Switched to ",
Expand Down
13 changes: 8 additions & 5 deletions src/agent_cli/commands/ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,11 +349,14 @@ def render_session_list(metas: list[SessionMeta]) -> RenderableType:

# ── /provider list ───────────────────────────────────────────────────

_PROFILE_EXAMPLE = """{
"llm_profiles": {
"<name>": { "provider": "..", "model": "..", "base_url": "..", "api_key": ".." }
}
}"""
_PROFILE_EXAMPLE = (
"{\n"
' "llm_profiles": {\n'
' "<name>": { "provider": "..", "model": "..", '
'"sub_model": "..", "base_url": "..", "api_key": ".." }\n'
" }\n"
"}"
)


def render_provider_list(
Expand Down
7 changes: 4 additions & 3 deletions src/agent_cli/runtime/goal/evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,8 @@ async def evaluate(
) -> GoalVerdict:
stm = agent.context.short_term_memory
stm_messages = await stm.get_context_messages()
model = agent.llm.model_name
llm = agent.sub_llm
model = llm.model_name
nonce = secrets.token_hex(16)

local_input_limit = stm.max_tokens * (1.0 - _CONTEXT_HEADROOM)
Expand Down Expand Up @@ -236,15 +237,15 @@ async def evaluate(
"input exceeds context limit; run /compact before resuming"
)
try:
response = await agent.llm.generate_with_events(messages)
response = await llm.generate_with_events(messages)
except Exception as exc:
raise GoalEvaluationError(
f"request failed: {type(exc).__name__}"
) from exc

agent.context.usage_meter.record(
response.usage,
model=agent.llm.model_name,
model=llm.model_name,
source="goal_eval",
)
content = response.message.content or ""
Expand Down
11 changes: 0 additions & 11 deletions src/agent_cli/runtime/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
from agent_harness.agent.base import BaseAgent
from agent_harness.approval.policy import ApprovalPolicy
from agent_harness.core.message import Message, Role
from agent_harness.llm import BaseLLM
from agent_harness.session.base import BaseSession, SessionState
from agent_harness.session.file_session import FileSession

Expand Down Expand Up @@ -81,16 +80,6 @@ def set_messages(agent: BaseAgent, messages: list[Message]) -> None:
agent.context.short_term_memory.replace_messages(messages)


def update_compressor_model(
agent: BaseAgent, new_model: str, new_llm: BaseLLM,
) -> None:
compressor = agent.context.short_term_memory.compressor
cfg = agent.context.config
if compressor is not None and cfg.memory.compression.summary_model is None:
compressor._model = new_model
compressor._llm = new_llm


async def resolve_session_id(
args: argparse.Namespace, probe: FileSession,
) -> str | None:
Expand Down
Loading
Loading