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: 2 additions & 2 deletions config-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@
"sensitive": false,
"tags": [],
"label": "Per-role models",
"help": "Optional per-task-class model overrides. Keys: 'background' (lite / heartbeat background workers) and 'subagent' (spawned sub-agents). An empty value or 'auto' defers to the chat default (agent.model) and then to the provider default, so an unpinned role stays usable on every subscription tier. Pin a cheaper model here to run background / sub-agent work on it without changing the interactive chat default.",
"help": "Optional per-task-class model overrides. Keys: 'background' (lite / heartbeat background workers) and 'subagent' (spawned sub-agents). An empty value or 'auto' defers to the chat default (agent.model) and then to the provider default, so an unpinned role stays usable on every subscription tier. Pin a cheaper model here to run background / sub-agent work on it without changing the interactive chat default. A 'subagent' pin runs each spawned sub-agent in its own runtime (its own agent process and MCP servers, about 0.55 GB of memory each) where it could otherwise share the parent chat's.",
"hasChildren": true,
"enumValues": null,
"defaultValue": {}
Expand Down Expand Up @@ -197,7 +197,7 @@
"sensitive": false,
"tags": [],
"label": "Per-role reasoning effort",
"help": "Optional per-task-class reasoning effort, paired with role_models (keys: 'background', 'subagent'). Empty for a role inherits the chat default (agent.reasoning_effort) and then the provider/model default. Only applies on reasoning-capable models.",
"help": "Optional per-task-class reasoning effort, paired with role_models (keys: 'background', 'subagent'). Empty for a role inherits the chat default (agent.reasoning_effort) and then the provider/model default. Only applies on reasoning-capable models. Like a model pin, a 'subagent' effort runs each spawned sub-agent in its own runtime (about 0.55 GB of memory each) where it could otherwise share the parent chat's.",
"hasChildren": true,
"enumValues": null,
"defaultValue": {}
Expand Down
10 changes: 8 additions & 2 deletions src/kiro_crew/config/sections_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,10 @@ class AgentConfig:
"(agent.model) and then to the provider default, so an unpinned "
"role stays usable on every subscription tier. Pin a cheaper model "
"here to run background / sub-agent work on it without changing the "
"interactive chat default.",
"interactive chat default. A 'subagent' pin runs each spawned "
"sub-agent in its own runtime (its own agent process and MCP "
"servers, about 0.55 GB of memory each) where it could otherwise "
"share the parent chat's.",
),
)
role_efforts: dict[str, str] = field(
Expand All @@ -110,7 +113,10 @@ class AgentConfig:
"Optional per-task-class reasoning effort, paired with role_models "
"(keys: 'background', 'subagent'). Empty for a role inherits the chat "
"default (agent.reasoning_effort) and then the provider/model default. "
"Only applies on reasoning-capable models.",
"Only applies on reasoning-capable models. Like a model pin, a "
"'subagent' effort runs each spawned sub-agent in its own runtime "
"(about 0.55 GB of memory each) where it could otherwise share the "
"parent chat's.",
),
)
model_context_tokens: dict[str, int] = field(
Expand Down
1 change: 1 addition & 0 deletions src/kiro_crew/dashboard/handlers/messaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,7 @@
from kiro_crew.spawn_warm import warm_project_agents_for_spawn # noqa: F401
from kiro_crew.subagent import ( # noqa: F401
DEFERRED_QUEUED_REASONS,
PINNED_RUNTIME_NOTE,
SUCCESSOR_UNKNOWN,
effort_applied_note,
effort_drop_reason,
Expand Down
39 changes: 39 additions & 0 deletions src/kiro_crew/dashboard/messaging_api/spawn.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
_SPAWN_REJECTED_CODE,
DEFERRED_QUEUED_REASONS,
NATIVE_CHILD_NOT_RESUMABLE,
PINNED_RUNTIME_NOTE,
SPAWN_RUN_SCHEMA,
DashboardState,
KiroCrewConfig,
Expand Down Expand Up @@ -492,6 +493,44 @@ def _effort_verdict() -> tuple[str, str]:
field, text = await asyncio.to_thread(_effort_verdict)
if field:
resp[field] = text

# Reporting only, like the effort verdict. Read from the run's own sharing
# plan, the one decision the start and admission take, so the note can
# never follow a copy of the pin rule. The plan reads config (file I/O),
# so off the loop; a crew spawn already loaded it above.
def _runtime_note() -> str:
manager = state.subagents
if manager is None:
return ""
import copy

from kiro_crew.subagent import _selection_kind

try:
# Only a template run can ever take the shared arm.
if _selection_kind(info) != "template":
return ""
plan = manager._sharing_plan(info, cfg=config)
pins = (getattr(plan, "eff_model", ""), getattr(plan, "eff_effort", ""))
if not any(isinstance(p, str) and p for p in pins):
return ""
if getattr(plan, "shared", None) is not False or getattr(info, "keep", False):
return ""
# Name the pin only when it is what took the share away: the same
# run without its own model would take the shared arm. A backend
# or setting that never shares gets no note.
unpinned = copy.copy(info)
unpinned.model = ""
unpinned.routed_model = ""
if manager._should_use_session_sharing(unpinned) is not True:
return ""
except Exception:
return ""
return PINNED_RUNTIME_NOTE

runtime_note = await asyncio.to_thread(_runtime_note)
if runtime_note:
resp["runtime_note"] = runtime_note
if keep:
# The conversation id is the FIRST run's id: spawn_continue targets it.
resp["conversation"] = info.id
Expand Down
10 changes: 10 additions & 0 deletions src/kiro_crew/mcp_tools/spawn.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,8 @@ def spawn_run(name: str, args: dict[str, Any]) -> str | DeferredTool:
# (subagent id, note) pairs for a level only session start can judge: with
# no model pinned the provider binds it to the model the backend serves.
effort_pendings: list[tuple[str, tuple[str, str]]] = []
# (subagent id, note) pairs for a run a pin starts on its own runtime.
runtime_notes: list[tuple[str, tuple[str, str]]] = []
agent_tasks: list[str] = []
# Each accepted member's label, "" where the call gave none.
agent_labels: list[str] = []
Expand Down Expand Up @@ -427,6 +429,8 @@ def _submit_one(i: int) -> None:
effort_applies.append((str(d.get("id", "?")), (t_effort, str(d["effort_applied"]))))
if d.get("effort_pending"):
effort_pendings.append((str(d.get("id", "?")), (t_effort, str(d["effort_pending"]))))
if d.get("runtime_note"):
runtime_notes.append((str(d.get("id", "?")), ("", str(d["runtime_note"]))))

def _receipt() -> str:
return _spawn_run_receipt(
Expand All @@ -437,6 +441,7 @@ def _receipt() -> str:
effort_drops=effort_drops,
effort_applies=effort_applies,
effort_pendings=effort_pendings,
runtime_notes=runtime_notes,
queued_reasons=queued_reasons,
errors=errors,
transport_errors=transport_errors,
Expand Down Expand Up @@ -470,6 +475,7 @@ def _spawn_run_receipt(
can_work: bool,
keep: bool,
agent_labels: list[str] | None = None,
runtime_notes: list[tuple[str, tuple[str, str]]] | None = None,
) -> str:
"""Render the accumulated background-run receipt without gateway requests."""
spawn_lines: list[str] = []
Expand All @@ -492,6 +498,10 @@ def _spawn_run_receipt(
# Neither a ✓ nor "dropped": the outcome is decided after this result.
for pending_ids, (lvl, pending_note) in _collapse_effort_verdicts(effort_pendings):
spawn_lines.append(f"ℹ reasoning_effort='{lvl}' for {pending_ids}: {pending_note}")
# Memory cost of a pin: it is the user's setting, not the call's, that
# usually carries it, so the receipt is where the caller first sees it.
for note_ids, (_unused, note) in _collapse_effort_verdicts(runtime_notes or []):
spawn_lines.append(f"ℹ {note_ids}: {note}")
if not parent_session and agent_ids:
# Orphan alert: without a parent session key the subagents cannot
# deliver completion events back to this conversation and will
Expand Down
1 change: 1 addition & 0 deletions src/kiro_crew/subagent.py
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@
)
from kiro_crew.subagent_parts.manager import SubagentManager
from kiro_crew.subagent_parts.model_choice import ( # noqa: F401
PINNED_RUNTIME_NOTE,
_effort_channel,
_effort_judged_at_session_start,
_resolved_model_of,
Expand Down
14 changes: 14 additions & 0 deletions src/kiro_crew/subagent_parts/model_choice.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,20 @@ def effort_drop_reason(
return ""


#: Receipt text for a run whose sharing plan carries a model or effort (a
#: per-call value, a ``subagent`` role pin, or a routed model) where the run
#: would otherwise share its parent's runtime: the pin forces a runtime of its
#: own. The size is the measured cost of one kiro-cli
#: process plus its three managed MCP servers on macOS; it is a guide for the
#: reader, not a limit anything enforces.
PINNED_RUNTIME_NOTE = (
"a set model or effort (per call, a Subagent Model/Effort setting, or "
"routing) starts it in its own runtime (its own agent process and MCP "
"servers, about 0.55 GB of memory each); without one it would share the "
"parent chat's runtime"
)


def effort_applied_note(
model: str,
reasoning_effort: str,
Expand Down
188 changes: 188 additions & 0 deletions test/test_spawn_pinned_runtime_note.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
"""The spawn receipt says when a set model or effort starts a run on its own runtime.

A model or effort in a run's sharing plan -- per call, the ``subagent`` role
pins in Settings, or a routed model -- sends it down the dedicated-process path
(``_sharing_plan_impl``), which costs one kiro-cli process plus its MCP servers
per run. ``POST /api/spawn`` reads that same plan for an additive
``runtime_note`` key, and ``spawn_run`` renders it once per batch.
"""

from __future__ import annotations

import asyncio
import json
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from kiro_crew.subagent import PINNED_RUNTIME_NOTE
from kiro_crew.subagent_parts.start_pricing import _SharingPlan

pytestmark = pytest.mark.usefixtures("healthy_host_memory")


def _api_spawn(
body: dict,
plan: Any,
*,
would_share: Any = True,
keep: bool = False,
selection_kind: str = "template",
) -> tuple[dict, MagicMock]:
from kiro_crew.config.loader import AgentConfig, KiroCrewConfig
from kiro_crew.dashboard.handlers.messaging import api_spawn
from kiro_crew.dashboard.state import _ChatSlot

mgr = MagicMock()
info = SimpleNamespace(
id="a1",
done=False,
error="",
model="sonnet-test-model",
routed_model="r",
keep=keep,
agent="",
execution_context=SimpleNamespace(selection_kind=selection_kind),
)
mgr.spawn.return_value = info
if isinstance(plan, Exception):
mgr._sharing_plan.side_effect = plan
else:
mgr._sharing_plan.return_value = plan
mgr._should_use_session_sharing.return_value = would_share
state = SimpleNamespace(
_slots={"1": _ChatSlot("1", memory_mode="persistent")},
_restricted_keys=set(),
subagents=mgr,
sessions=SimpleNamespace(
get_agent=lambda key: "",
get_agent_selection=MagicMock(return_value=("template", "")),
),
conversation_log=SimpleNamespace(get_metadata_status=lambda key: ({}, True)),
)
request = MagicMock()
request.app = {"state": state}
request.headers = {"X-Session-Key": body.get("parent_session", "")}
request.json = AsyncMock(return_value=body)
cfg = KiroCrewConfig(agent=AgentConfig())
with (
patch("kiro_crew.config.loader.KiroCrewConfig.load", classmethod(lambda c: cfg)),
patch(
"kiro_crew.dashboard.handlers.messaging.warm_project_agents_for_spawn",
AsyncMock(),
),
):
resp = asyncio.run(api_spawn(request))
assert resp.status == 200
mgr.spawn_info = info
return json.loads(resp.body), mgr


_BODY = {"task": "x", "parent_session": "dashboard:1"}


class TestApiSpawnRuntimeNote:
@pytest.mark.parametrize(
"plan",
[
_SharingPlan("sonnet-test-model", "", False),
_SharingPlan("", "high", False),
_SharingPlan("routed-model", "high", False),
],
)
def test_a_model_or_effort_in_the_plan_adds_the_key(self, plan):
data, _ = _api_spawn(_BODY, plan)
assert data["runtime_note"] == PINNED_RUNTIME_NOTE
assert data["status"] == "spawned"

@pytest.mark.parametrize("plan", [_SharingPlan("", "", True), _SharingPlan("", "", False)])
def test_no_model_or_effort_omits_the_key(self, plan):
"""A dedicated run with no pin (keep, a member, an AWS profile) is not
credited to a pin it does not have."""
data, _ = _api_spawn(_BODY, plan)
assert "runtime_note" not in data
assert data["id"] == "a1"

def test_the_note_reads_the_runs_own_plan(self):
_, mgr = _api_spawn(_BODY, _SharingPlan("m", "", False))
args, _kwargs = mgr._sharing_plan.call_args
assert args == (mgr.spawn_info,)

@pytest.mark.parametrize("would_share", [False, None, MagicMock()])
def test_no_note_where_the_run_could_never_share(self, would_share):
"""A backend outside session sharing, ``session_sharing`` off or an
ineligible parent: the pin took nothing away, so nothing is said."""
data, _ = _api_spawn(_BODY, _SharingPlan("m", "high", False), would_share=would_share)
assert "runtime_note" not in data

def test_no_note_for_a_member_run(self):
"""A member run never takes the shared arm, even one whose context
carries no member id (a v1-store member)."""
data, mgr = _api_spawn(_BODY, _SharingPlan("m", "", False), selection_kind="member")
assert "runtime_note" not in data
mgr._should_use_session_sharing.assert_not_called()

def test_no_note_for_a_kept_run(self):
"""A kept run owns its process whatever its model."""
data, _ = _api_spawn(_BODY, _SharingPlan("m", "", False), keep=True)
assert "runtime_note" not in data

def test_the_share_question_is_asked_without_the_runs_own_model(self):
"""The run's own model is what turns sharing off, so the 'would it
share otherwise' question drops it -- on a copy, never the run's info."""
_, mgr = _api_spawn(_BODY, _SharingPlan("sonnet-test-model", "", False))
(asked,), _ = mgr._should_use_session_sharing.call_args
assert asked is not mgr.spawn_info
assert asked.model == "" and asked.routed_model == ""
assert mgr.spawn_info.model == "sonnet-test-model"
assert mgr.spawn_info.routed_model == "r"

@pytest.mark.parametrize("plan", [RuntimeError("plan failed"), MagicMock()])
def test_an_unreadable_plan_never_fails_the_spawn(self, plan):
data, _ = _api_spawn(_BODY, plan)
assert "runtime_note" not in data
assert data["status"] == "spawned"

def test_note_names_the_cost(self):
assert "own runtime" in PINNED_RUNTIME_NOTE
assert "0.55 GB" in PINNED_RUNTIME_NOTE


def _receipt(responses: list[dict[str, Any]], tasks: list[str]) -> str:
from kiro_crew import mcp_core

replies = iter(responses)

def _fake_post(path: str, body: dict) -> dict:
return next(replies) if path == "/api/spawn" else {}

with (
patch.object(mcp_core, "_post", side_effect=_fake_post),
patch.object(mcp_core, "_resolve_session_key", return_value="dashboard:chat-1"),
patch.object(mcp_core, "sel", MagicMock()),
):
return mcp_core._call_tool("spawn_run", {"tasks": tasks})


class TestReceipt:
def test_note_is_rendered_once_for_the_batch(self):
result = _receipt(
[{"id": f"a{i}", "runtime_note": "pinned-note"} for i in range(3)],
["t0", "t1", "t2"],
)
assert "ℹ a0, a1, a2: pinned-note" in result
assert result.count("pinned-note") == 1

def test_no_note_without_the_key(self):
result = _receipt([{"id": "a0"}, {"id": "a1"}], ["t0", "t1"])
assert "own runtime" not in result
assert result.startswith("Spawned 2 subagent(s).")

def test_note_never_shadows_the_error_prefix(self):
result = _receipt(
[{"error": "nope", "counted": True}, {"error": "nope", "counted": True}],
["t0", "t1"],
)
assert result.startswith("Error:")
1 change: 1 addition & 0 deletions website/src/i18n/locales/bn.json
Original file line number Diff line number Diff line change
Expand Up @@ -16367,6 +16367,7 @@
"model_default_applies_no_override_the_model_pick": "ডিফল্টে উত্তর দেওয়ার আগে মডেল কতক্ষণ ভাবে। 'মডেল ডিফল্ট' কোনো ওভাররাইড করে না — মডেল নিজেই তার এফোর্ট বেছে নেয়।",
"model_for_background_lite_heartbeat_work": "ব্যাকগ্রাউন্ড কাজের জন্য মডেল — শিরোনাম তৈরি, ইতিহাস কম্প্যাকশন এবং হার্টবিট পোলিং।",
"model_for_spawned_sub_agents": "স্পন করা সাব-এজেন্টের জন্য মডেল।",
"subagent_pin_runtime_cost": "সাব-এজেন্ট মডেল বা সাব-এজেন্ট এফোর্ট ডিফল্ট ছাড়া অন্য কিছুতে সেট করলে প্রতিটি সাব-এজেন্ট নিজস্ব প্রসেসে চলে, প্রতিটিতে প্রায় 0.55 GB মেমরি লাগে, যা 16 GB বা তার কম মেমরির মেশিনকে ধীর করতে পারে। দুটোই ডিফল্টে থাকলে সাব-এজেন্টরা চ্যাটের প্রসেস শেয়ার করতে পারে।",
"model_tried_when_your_current_model_stays_rate_li": "আপনার বর্তমান মডেল পুনঃচেষ্টার বাজেট পেরিয়েও রেট-সীমিত থাকলে যে মডেলটি চেষ্টা করা হয়।",
"more_encourage_widgets": "বেশি (উইজেট উৎসাহিত করে)",
"multiline": "একাধিক লাইন",
Expand Down
1 change: 1 addition & 0 deletions website/src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -16367,6 +16367,7 @@
"model_default_applies_no_override_the_model_pick": "Wie lange Modelle standardmäßig nachdenken, bevor sie antworten. „Modellstandard“ wendet keine Überschreibung an — das Modell wählt seinen Aufwand selbst.",
"model_for_background_lite_heartbeat_work": "Modell für Hintergrundarbeit – Titelerzeugung, Verlaufskomprimierung und Heartbeat-Abfrage.",
"model_for_spawned_sub_agents": "Modell für erzeugte Subagenten.",
"subagent_pin_runtime_cost": "Wenn Subagent-Modell oder Subagent-Aufwand nicht auf dem Standard steht, läuft jeder Subagent in einem eigenen Prozess mit jeweils etwa 0,55 GB Speicher, was Rechner mit 16 GB oder weniger verlangsamen kann. Stehen beide auf dem Standard, können Subagenten den Prozess des Chats mitnutzen.",
"model_tried_when_your_current_model_stays_rate_li": "Modell, das verwendet wird, wenn Ihr aktuelles Modell über das Wiederholungsbudget hinaus ratenbegrenzt bleibt.",
"more_encourage_widgets": "Mehr (Widgets fördern)",
"multiline": "Mehrzeilig",
Expand Down
Loading
Loading